diff --git a/api/package.json b/api/package.json index 175c003e333..e8f54f2770b 100644 --- a/api/package.json +++ b/api/package.json @@ -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" }, diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts index 531d29be820..83bf641552f 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/auth.ts @@ -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: { diff --git a/api/src/routes/user.test.ts b/api/src/routes/user.test.ts index 2348c9c4d60..37ac345cca3 100644 --- a/api/src/routes/user.test.ts +++ b/api/src/routes/user.test.ts @@ -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', { diff --git a/api/src/routes/user.ts b/api/src/routes/user.ts index f5543d404c1..013c788d16b 100644 --- a/api/src/routes/user.ts +++ b/api/src/routes/user.ts @@ -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 => { diff --git a/api/src/schema.test.ts b/api/src/schema.test.ts index fcbc7e5ffb5..d417599e17d 100644 --- a/api/src/schema.test.ts +++ b/api/src/schema.test.ts @@ -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(); + }); }); }); }); - }); }); diff --git a/api/src/schemas.ts b/api/src/schemas.ts index bbc74b101b8..2196c780f88 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -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: { diff --git a/api/src/utils/default-user.ts b/api/src/utils/default-user.ts new file mode 100644 index 00000000000..65920fef981 --- /dev/null +++ b/api/src/utils/default-user.ts @@ -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 = { + 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: '' +}; diff --git a/api/src/utils/normalize.test.ts b/api/src/utils/normalize.test.ts new file mode 100644 index 00000000000..2bb18962ca0 --- /dev/null +++ b/api/src/utils/normalize.test.ts @@ -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' + } + ] + } + ]); + }); + }); +}); diff --git a/api/src/utils/normalize.ts b/api/src/utils/normalize.ts new file mode 100644 index 00000000000..fabfba7940a --- /dev/null +++ b/api/src/utils/normalize.ts @@ -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 extends null ? undefined : T; + +type NoNullProperties = { + [P in keyof T]: NullToUndefined; +}; + +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 => { + 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 = >( + obj: T +): NoNullProperties => + _.pickBy(obj, value => value !== null) as NoNullProperties; + +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; +}; diff --git a/api/src/utils/progress.test.ts b/api/src/utils/progress.test.ts new file mode 100644 index 00000000000..1158f622229 --- /dev/null +++ b/api/src/utils/progress.test.ts @@ -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); + }); + }); +}); diff --git a/api/src/utils/progress.ts b/api/src/utils/progress.ts new file mode 100644 index 00000000000..b10d586c75c --- /dev/null +++ b/api/src/utils/progress.ts @@ -0,0 +1,24 @@ +export type ProgressTimestamp = number | { timestamp: number } | null; + +export const getCalendar = ( + progressTimestamps: ProgressTimestamp[] | null +): Record => { + const calendar: Record = {}; + + 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; +}; diff --git a/client/src/utils/challenge-request-helpers.ts b/client/src/utils/challenge-request-helpers.ts index d4017894beb..ee08c18adb3 100644 --- a/client/src/utils/challenge-request-helpers.ts +++ b/client/src/utils/challenge-request-helpers.ts @@ -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 }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae1d6284fad..3555d1a2629 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: