From 5482650dd0b18f5da38d805912b771f4b33f52e3 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Mon, 17 Jul 2023 10:03:17 +0200 Subject: [PATCH] feat(api): project-completed (#50701) Co-authored-by: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com> Co-authored-by: Niraj Nandish --- api/package.json | 8 +- api/src/app.ts | 27 ++ api/src/routes/challenge.test.ts | 318 ++++++++++++++++++ api/src/routes/challenge.ts | 109 ++++++ .../routes/helpers/challenge-helpers.test.ts | 71 ++++ api/src/routes/helpers/challenge-helpers.ts | 37 ++ api/src/schemas.ts | 63 +++- api/src/utils/error-formatting.test.ts | 72 ++++ api/src/utils/error-formatting.ts | 32 ++ api/src/utils/validation.test.ts | 16 + api/src/utils/validation.ts | 6 + pnpm-lock.yaml | 26 +- 12 files changed, 755 insertions(+), 30 deletions(-) create mode 100644 api/src/routes/challenge.test.ts create mode 100644 api/src/routes/challenge.ts create mode 100644 api/src/routes/helpers/challenge-helpers.test.ts create mode 100644 api/src/routes/helpers/challenge-helpers.ts create mode 100644 api/src/utils/error-formatting.test.ts create mode 100644 api/src/utils/error-formatting.ts create mode 100644 api/src/utils/validation.test.ts create mode 100644 api/src/utils/validation.ts diff --git a/api/package.json b/api/package.json index 857fa07d8a7..6bb52c07a3d 100644 --- a/api/package.json +++ b/api/package.json @@ -12,14 +12,17 @@ "@fastify/swagger-ui": "^1.5.0", "@immobiliarelabs/fastify-sentry": "^6.0.0", "@prisma/client": "4.16.2", - "connect-mongo": "4.6.0", + "ajv": "8.12.0", + "ajv-formats": "^2.1.1", "bad-words": "3.0.4", + "connect-mongo": "4.6.0", + "fast-uri": "2.2.0", "fastify": "4.19.2", "fastify-auth0-verify": "^1.0.0", "fastify-plugin": "^4.3.0", "jsonwebtoken": "9.0.1", + "mongodb": "^4.16.0", "nanoid": "3", - "mongodb": "4", "nodemon": "2.0.22", "query-string": "^7.1.3" }, @@ -30,7 +33,6 @@ "@types/express-session": "1.17.7", "@types/jsonwebtoken": "^9.0.2", "@types/supertest": "2.0.12", - "ajv": "8.12.0", "dotenv-cli": "7.2.1", "jest": "29.6.1", "pino-pretty": "10.0.1", diff --git a/api/src/app.ts b/api/src/app.ts index 86d89b3b917..cf26b761e4c 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -7,6 +7,7 @@ import Fastify, { RawRequestDefaultExpression, RawServerDefault } from 'fastify'; +import Ajv from 'ajv'; import middie from '@fastify/middie'; import fastifySession from '@fastify/session'; import fastifyCookie from '@fastify/cookie'; @@ -16,6 +17,8 @@ import fastifySwagger from '@fastify/swagger'; import fastifySwaggerUI from '@fastify/swagger-ui'; import fastifyCsrfProtection from '@fastify/csrf-protection'; import fastifySentry from '@immobiliarelabs/fastify-sentry'; +import uriResolver from 'fast-uri'; +import addFormats from 'ajv-formats'; import cors from './plugins/cors'; import jwtAuthz from './plugins/fastify-jwt-authz'; @@ -40,10 +43,12 @@ import { FCC_ENABLE_DEV_LOGIN_MODE, SENTRY_DSN } from './utils/env'; +import { challengeRoutes } from './routes/challenge'; import { userRoutes } from './routes/user'; import { donateRoutes } from './routes/donate'; import { statusRoute } from './routes/status'; import { unsubscribeDeprecated } from './routes/deprecated-unsubscribe'; +import { isObjectID } from './utils/validation'; export type FastifyInstanceWithTypeProvider = FastifyInstance< RawServerDefault, @@ -53,6 +58,25 @@ export type FastifyInstanceWithTypeProvider = FastifyInstance< TypeBoxTypeProvider >; +// Options that fastify uses +const ajv = new Ajv({ + coerceTypes: 'array', // change data type of data to match type keyword + useDefaults: true, // replace missing properties and items with the values from corresponding default keyword + removeAdditional: true, // remove additional properties + uriResolver, + addUsedSchema: false, + // Explicitly set allErrors to `false`. + // When set to `true`, a DoS attack is possible. + allErrors: false +}); + +// add the default formatters from avj-formats +addFormats(ajv); +ajv.addFormat('objectid', { + type: 'string', + validate: (str: string) => isObjectID(str) +}); + export const build = async ( options: FastifyHttpOptions = {} ): Promise => { @@ -60,6 +84,8 @@ export const build = async ( // Watch when implementing in client const fastify = Fastify(options).withTypeProvider(); + fastify.setValidatorCompiler(({ schema }) => ajv.compile(schema)); + void fastify.register(security); fastify.get('/', async (_request, _reply) => { @@ -169,6 +195,7 @@ export const build = async ( if (FCC_ENABLE_DEV_LOGIN_MODE) { void fastify.register(devLoginCallback, { prefix: '/auth' }); } + void fastify.register(challengeRoutes); void fastify.register(settingRoutes); void fastify.register(donateRoutes); void fastify.register(userRoutes); diff --git a/api/src/routes/challenge.test.ts b/api/src/routes/challenge.test.ts new file mode 100644 index 00000000000..50e571c4cb4 --- /dev/null +++ b/api/src/routes/challenge.test.ts @@ -0,0 +1,318 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { setupServer, superRequest } from '../../jest.utils'; + +const isValidChallengeCompletionErrorMsg = { + type: 'error', + message: 'That does not appear to be a valid challenge submission.' +}; + +const id1 = 'bd7123c8c441eddfaeb5bdef'; +const id2 = 'bd7123c8c441eddfaeb5bdec'; + +const codeallyProject = { + id: id1, + challengeType: 13, + solution: 'https://any.valid/url' +}; +const backendProject = { + id: id2, + challengeType: 4, + solution: 'https://any.valid/url', + githubLink: 'https://github.com/anything/valid/' +}; + +const partialCompletion = { id: id1, completedDate: 1 }; + +describe('challengeRoutes', () => { + setupServer(); + describe('Authenticated user', () => { + let setCookies: string[]; + + // Authenticate user + beforeAll(async () => { + const res = await superRequest('/auth/dev-callback', { method: 'GET' }); + expect(res.status).toBe(200); + setCookies = res.get('Set-Cookie'); + }); + + describe('/project-completed', () => { + describe('validation', () => { + it('POST rejects requests without ids', async () => { + const response = await superRequest('/project-completed', { + method: 'POST', + setCookies + }).send({}); + + expect(response.body).toStrictEqual( + isValidChallengeCompletionErrorMsg + ); + expect(response.statusCode).toBe(400); + }); + + it('POST rejects requests without valid ObjectIDs', async () => { + const response = await superRequest('/project-completed', { + method: 'POST', + setCookies + // This is a departure from api-server, which does not require a + // solution to give this error. However, the validator will reject + // based on the missing solution before it gets to the invalid id. + }).send({ id: 'not-a-valid-id', solution: '' }); + + expect(response.body).toStrictEqual( + isValidChallengeCompletionErrorMsg + ); + expect(response.statusCode).toBe(400); + }); + + it('POST rejects requests with invalid challengeTypes', async () => { + const response = await superRequest('/project-completed', { + method: 'POST', + setCookies + }).send({ + id: id1, + challengeType: 'not-a-valid-challenge-type', + // TODO(Post-MVP): drop these comments, since the api-server will not + // exist. + + // a solution is required, because otherwise the request will be + // rejected before it gets to the challengeType validation. NOTE: this + // is a departure from the api-server, but only in the message sent. + solution: '' + }); + + expect(response.body).toStrictEqual( + isValidChallengeCompletionErrorMsg + ); + expect(response.statusCode).toBe(400); + }); + + it('POST rejects requests without solutions', async () => { + const response = await superRequest('/project-completed', { + method: 'POST', + setCookies + }).send({ + id: id1, + challengeType: 3 + }); + + expect(response.body).toStrictEqual({ + type: 'error', + message: + 'You have not provided the valid links for us to inspect your work.' + }); + expect(response.statusCode).toBe(400); + }); + + it('POST rejects requests with solutions that are not urls', async () => { + const response = await superRequest('/project-completed', { + method: 'POST', + setCookies + }).send({ + id: id1, + challengeType: 3, + solution: 'not-a-valid-solution' + }); + + expect(response.body).toStrictEqual( + isValidChallengeCompletionErrorMsg + ); + expect(response.statusCode).toBe(400); + }); + + it('POST rejects CodeRoad/CodeAlly projects when the user has not completed the required challenges', async () => { + const response = await superRequest('/project-completed', { + method: 'POST', + setCookies + }).send({ + id: id1, // not a codeally challenge id, but does not matter + challengeType: 13, // this does matter, however, since there's special logic for that challenge type + solution: 'https://any.valid/url' + }); + + expect(response.body).toStrictEqual({ + type: 'error', + message: + 'You have to complete the project before you can submit a URL.' + }); + // It's not really a bad request, since the client is sending a valid + // body. It's just that the user is not allowed to do this - hence 403. + expect(response.statusCode).toBe(403); + }); + }); + + describe('handling', () => { + beforeEach(async () => { + // setup: complete the challenges that codeally projects require + await fastifyTestInstance.prisma.user.updateMany({ + where: { email: 'foo@bar.com' }, + data: { + partiallyCompletedChallenges: [{ id: id1, completedDate: 1 }], + completedChallenges: [], + progressTimestamps: [] + } + }); + }); + + afterEach(async () => { + await fastifyTestInstance.prisma.user.updateMany({ + where: { email: 'foo@bar.com' }, + data: { + partiallyCompletedChallenges: [], + completedChallenges: [], + savedChallenges: [] + } + }); + }); + + it('POST accepts CodeRoad/CodeAlly projects when the user has completed the required challenges', async () => { + const now = Date.now(); + const response = await superRequest('/project-completed', { + method: 'POST', + setCookies + }).send(codeallyProject); + + const user = await fastifyTestInstance.prisma.user.findFirst({ + where: { email: 'foo@bar.com' } + }); + + expect(user).toMatchObject({ + partiallyCompletedChallenges: [], + completedChallenges: [ + { + ...codeallyProject, + completedDate: expect.any(Number) + } + ] + }); + + const completedDate = user?.completedChallenges[0]?.completedDate; + + // TODO: use a custom matcher for this + expect(completedDate).toBeGreaterThan(now); + expect(completedDate).toBeLessThan(now + 1000); + + expect(response.body).toStrictEqual({ + alreadyCompleted: false, + points: 1, + completedDate + }); + + expect(response.statusCode).toBe(200); + }); + + it('POST accepts backend projects', async () => { + const now = Date.now(); + + const response = await superRequest('/project-completed', { + method: 'POST', + setCookies + }).send(backendProject); + + const user = await fastifyTestInstance.prisma.user.findFirst({ + where: { email: 'foo@bar.com' } + }); + + expect(user).toMatchObject({ + partiallyCompletedChallenges: [partialCompletion], + completedChallenges: [ + { + ...backendProject, + completedDate: expect.any(Number) + } + ] + }); + + const completedDate = user?.completedChallenges[0]?.completedDate; + + // TODO: use a custom matcher for this + expect(completedDate).toBeGreaterThan(now); + expect(completedDate).toBeLessThan(now + 1000); + + expect(response.body).toStrictEqual({ + alreadyCompleted: false, + points: 1, + completedDate + }); + + expect(response.statusCode).toBe(200); + }); + + it('POST correctly handles multiple requests', async () => { + const resOriginal = await superRequest('/project-completed', { + method: 'POST', + setCookies + }).send(codeallyProject); + + const resBackend = await superRequest('/project-completed', { + method: 'POST', + setCookies + }).send(backendProject); + + // sending backendProject again should update its solution, but not + // progressTimestamps or its completedDate + + const resUpdate = await superRequest('/project-completed', { + method: 'POST', + setCookies + }).send({ ...codeallyProject, solution: 'https://any.other/url' }); + + const user = await fastifyTestInstance.prisma.user.findFirst({ + where: { email: 'foo@bar.com' } + }); + + const expectedProgressTimestamps = user?.completedChallenges.map( + challenge => challenge.completedDate + ); + + expect(user).toMatchObject({ + completedChallenges: [ + { + ...codeallyProject, + solution: 'https://any.other/url', + completedDate: resOriginal.body.completedDate + }, + { + ...backendProject, + completedDate: resBackend.body.completedDate + } + ], + progressTimestamps: expectedProgressTimestamps + }); + + expect(resUpdate.body).toStrictEqual({ + alreadyCompleted: true, + points: 2, + completedDate: expect.any(Number) + }); + + // It should return an updated completedDate + expect(resUpdate.body.completedDate).not.toBe( + resOriginal.body.completedDate + ); + expect(resUpdate.statusCode).toBe(200); + }); + }); + }); + }); + describe('Unauthenticated user', () => { + let setCookies: string[]; + + // Get the CSRF cookies from an unprotected route + beforeAll(async () => { + const res = await superRequest('/', { method: 'GET' }); + setCookies = res.get('Set-Cookie'); + }); + + describe('/project-completed', () => { + test('POST returns 401 status code with error message', async () => { + const response = await superRequest('/project-completed', { + method: 'POST', + setCookies + }); + + expect(response?.statusCode).toBe(401); + }); + }); + }); +}); diff --git a/api/src/routes/challenge.ts b/api/src/routes/challenge.ts new file mode 100644 index 00000000000..42a4072e121 --- /dev/null +++ b/api/src/routes/challenge.ts @@ -0,0 +1,109 @@ +import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox'; + +import { formatValidationError } from '../utils/error-formatting'; +import { ProgressTimestamp, getPoints } from '../utils/progress'; +import { schemas } from '../schemas'; +import { + canSubmitCodeRoadCertProject, + createProject, + updateProject +} from './helpers/challenge-helpers'; + +export const challengeRoutes: FastifyPluginCallbackTypebox = ( + fastify, + _options, + done +) => { + // eslint-disable-next-line @typescript-eslint/unbound-method + fastify.addHook('onRequest', fastify.csrfProtection); + fastify.addHook('onRequest', fastify.authenticateSession); + + fastify.post( + '/project-completed', + { + schema: schemas.projectCompleted, + errorHandler(error, request, reply) { + if (error.validation) { + void reply.code(400); + return formatValidationError(error.validation); + } else { + fastify.errorHandler(error, request, reply); + } + } + }, + async (req, reply) => { + const { id: projectId, challengeType, solution, githubLink } = req.body; + const userId = req.session.user.id; + + try { + const user = await fastify.prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { + completedChallenges: true, + partiallyCompletedChallenges: true, + progressTimestamps: true + } + }); + + if ( + challengeType === 13 && + !canSubmitCodeRoadCertProject(projectId, user) + ) { + void reply.code(403); + return { + type: 'error', + message: + 'You have to complete the project before you can submit a URL.' + } as const; + } + + const completedDate = Date.now(); + const oldChallenge = user.completedChallenges?.find( + ({ id }) => id === projectId + ); + const updatedChallenge = { + challengeType, + solution, + githubLink + }; + const newChallenge = { + ...updatedChallenge, + id: projectId, + completedDate + }; + const alreadyCompleted = !!oldChallenge; + const progressTimestamps = + user.progressTimestamps as ProgressTimestamp[]; + const points = getPoints(progressTimestamps); + + const data = alreadyCompleted + ? updateProject(projectId, updatedChallenge) + : createProject(projectId, newChallenge, progressTimestamps); + + await fastify.prisma.user.update({ + where: { id: userId }, + data + }); + + return { + alreadyCompleted, + // TODO(Post-MVP): audit the client and remove this if the client does + // not use it. + completedDate, + points: alreadyCompleted ? points : points + 1 + }; + } catch (err) { + // TODO: send to Sentry + fastify.log.error(err); + void reply.code(500); + return { + message: + 'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.', + type: 'danger' + } as const; + } + } + ); + + done(); +}; diff --git a/api/src/routes/helpers/challenge-helpers.test.ts b/api/src/routes/helpers/challenge-helpers.test.ts new file mode 100644 index 00000000000..b36974fb02a --- /dev/null +++ b/api/src/routes/helpers/challenge-helpers.test.ts @@ -0,0 +1,71 @@ +import type { + PartiallyCompletedChallenge, + CompletedChallenge +} from '@prisma/client'; + +import { canSubmitCodeRoadCertProject } from './challenge-helpers'; + +const id = 'abc'; + +const partiallyCompletedChallenges: PartiallyCompletedChallenge[] = [ + { + id, + completedDate: 1 + } +]; +const completedChallenges: CompletedChallenge[] = [ + { + id, + completedDate: 1, + challengeType: 1, + files: [], + githubLink: null, + solution: null, + isManuallyApproved: false + } +]; + +describe('Challenge Helpers', () => { + describe('canSubmitCodeRoadCertProject', () => { + it('returns true if the user has completed the required challenges or partially completed them', () => { + expect( + canSubmitCodeRoadCertProject(id, { + partiallyCompletedChallenges, + completedChallenges + }) + ).toBe(true); + + expect( + canSubmitCodeRoadCertProject(id, { + partiallyCompletedChallenges: [], + completedChallenges + }) + ).toBe(true); + + expect( + canSubmitCodeRoadCertProject(id, { + partiallyCompletedChallenges, + completedChallenges: [] + }) + ).toBe(true); + }); + + it('returns false if the user has not completed the required challenges', () => { + expect( + canSubmitCodeRoadCertProject(id, { + partiallyCompletedChallenges: [], + completedChallenges: [] + }) + ).toBe(false); + }); + + it('returns false if the id is undefined', () => { + expect( + canSubmitCodeRoadCertProject(undefined, { + partiallyCompletedChallenges, + completedChallenges + }) + ).toBe(false); + }); + }); +}); diff --git a/api/src/routes/helpers/challenge-helpers.ts b/api/src/routes/helpers/challenge-helpers.ts new file mode 100644 index 00000000000..5661a5c8fe6 --- /dev/null +++ b/api/src/routes/helpers/challenge-helpers.ts @@ -0,0 +1,37 @@ +import type { Prisma } from '@prisma/client'; +import type { ProgressTimestamp } from '../../utils/progress'; + +export const canSubmitCodeRoadCertProject = ( + id: string | undefined, + { + partiallyCompletedChallenges, + completedChallenges + }: { + partiallyCompletedChallenges: { id: string }[]; + completedChallenges: { id: string }[]; + } +) => { + if (partiallyCompletedChallenges.some(c => c.id === id)) return true; + if (completedChallenges.some(c => c.id === id)) return true; + return false; +}; + +export const updateProject = ( + id: string, + newChallenge: Prisma.CompletedChallengeUpdateInput +) => ({ + completedChallenges: { + updateMany: { where: { id }, data: newChallenge } + }, + partiallyCompletedChallenges: { deleteMany: { where: { id } } } +}); + +export const createProject = ( + id: string, + newChallenge: Prisma.CompletedChallengeCreateInput, + progressTimestamps: ProgressTimestamp[] +) => ({ + completedChallenges: { push: newChallenge }, + partiallyCompletedChallenges: { deleteMany: { where: { id } } }, + progressTimestamps: [...progressTimestamps, newChallenge.completedDate] +}); diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 2196c780f88..43883906f31 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -1,5 +1,12 @@ import { Type } from '@fastify/type-provider-typebox'; +const generic500 = Type.Object({ + message: Type.Literal( + 'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.' + ), + type: Type.Literal('danger') +}); + export const schemas = { // Settings: updateMyProfileUI: { @@ -165,23 +172,13 @@ export const schemas = { deleteMyAccount: { response: { 200: Type.Object({}), - 500: Type.Object({ - message: Type.Literal( - 'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.' - ), - type: Type.Literal('danger') - }) + 500: generic500 } }, resetMyProgress: { response: { 200: Type.Object({}), - 500: Type.Object({ - message: Type.Literal( - 'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.' - ), - type: Type.Literal('danger') - }) + 500: generic500 } }, getSessionUser: { @@ -324,5 +321,47 @@ export const schemas = { }) }) } + }, + // Challenges: + projectCompleted: { + body: Type.Object({ + id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }), + challengeType: Type.Optional(Type.Number()), + solution: Type.String({ format: 'url', maxLength: 1024 }), + // TODO(Post-MVP): require format: 'url' for githubLink + githubLink: Type.Optional(Type.String()) + }), + response: { + 200: Type.Object({ + // TODO(Post-MVP): delete completedDate and alreadyCompleted? As far as + // I can tell, they are not used anywhere + completedDate: Type.Number(), + points: Type.Number(), + alreadyCompleted: Type.Boolean() + }), + 400: Type.Object({ + type: Type.Literal('error'), + message: Type.Union([ + Type.Literal( + 'That does not appear to be a valid challenge submission.' + ), + Type.Literal( + 'You have not provided the valid links for us to inspect your work.' + ) + ]) + }), + 403: Type.Object({ + type: Type.Literal('error'), + message: Type.Literal( + 'You have to complete the project before you can submit a URL.' + ) + }), + 500: Type.Object({ + message: Type.Literal( + 'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.' + ), + type: Type.Literal('danger') + }) + } } }; diff --git a/api/src/utils/error-formatting.test.ts b/api/src/utils/error-formatting.test.ts new file mode 100644 index 00000000000..51531c1c57f --- /dev/null +++ b/api/src/utils/error-formatting.test.ts @@ -0,0 +1,72 @@ +import { ErrorObject } from 'ajv'; +import { formatValidationError } from './error-formatting'; + +const missingSolutionError = { + instancePath: '', + schemaPath: '#/required', + keyword: 'required', + params: { missingProperty: 'solution' }, + message: "must have required property 'solution'" +}; + +const missingIdError = { + instancePath: '', + schemaPath: '#/required', + keyword: 'required', + params: { missingProperty: 'id' }, + message: "must have required property 'id'" +}; + +describe('Error formatting', () => { + describe('formatValidationError', () => { + it('should handle missing solutions', () => { + const formattedError = formatValidationError([missingSolutionError]); + + expect(formattedError).toStrictEqual({ + type: 'error', + message: + 'You have not provided the valid links for us to inspect your work.' + }); + }); + + it('should handle missing ids', () => { + const formattedError = formatValidationError([missingIdError]); + + expect(formattedError).toStrictEqual({ + type: 'error', + + message: 'That does not appear to be a valid challenge submission.' + }); + }); + + it('should return a generic error message for other errors', () => { + const formattedError = formatValidationError([ + { + ...missingSolutionError, + params: { missingProperty: 'notSolution' } + } + ]); + + expect(formattedError).toStrictEqual({ + type: 'error', + message: 'That does not appear to be a valid challenge submission.' + }); + }); + + it('should throw if passed zero errors', () => { + expect(() => formatValidationError([] as ErrorObject[])).toThrow( + Error( + 'Bad Argument: the array of errors must have exactly one element.' + ) + ); + }); + + it('should throw if passed more than one error', () => { + expect(() => formatValidationError([{}, {}] as ErrorObject[])).toThrow( + Error( + 'Bad Argument: the array of errors must have exactly one element.' + ) + ); + }); + }); +}); diff --git a/api/src/utils/error-formatting.ts b/api/src/utils/error-formatting.ts new file mode 100644 index 00000000000..cd31097a88e --- /dev/null +++ b/api/src/utils/error-formatting.ts @@ -0,0 +1,32 @@ +import { ErrorObject } from 'ajv'; + +export type FormattedError = { + type: 'error'; + message: + | 'You have not provided the valid links for us to inspect your work.' + | 'That does not appear to be a valid challenge submission.' + // the next isn't generated here, but the type is more general. + | 'You have to complete the project before you can submit a URL.'; +}; + +// This only formats invalid challenge submission for now. +export const formatValidationError = ( + errors: ErrorObject[] +): FormattedError => { + if (errors.length !== 1) { + throw new Error( + 'Bad Argument: the array of errors must have exactly one element.' + ); + } + + return errors[0]?.params.missingProperty === 'solution' + ? { + type: 'error', + message: + 'You have not provided the valid links for us to inspect your work.' + } + : { + type: 'error', + message: 'That does not appear to be a valid challenge submission.' + }; +}; diff --git a/api/src/utils/validation.test.ts b/api/src/utils/validation.test.ts new file mode 100644 index 00000000000..38e5512765d --- /dev/null +++ b/api/src/utils/validation.test.ts @@ -0,0 +1,16 @@ +import { isObjectID } from './validation'; + +describe('Validation', () => { + describe('isObjectID', () => { + it('returns true for valid ObjectIDs', () => { + expect(isObjectID('5f1e0f3b5d2c12b0b8f7a6b9')).toBe(true); + }); + + it('returns false for invalid ObjectIDs', () => { + expect(isObjectID('5f1e0f3b5d2c12b0b8f7a6b')).toBe(false); + expect(isObjectID('5f1e0f3b5d2c12b0b8f7a6b99')).toBe(false); + expect(isObjectID('5f1e0f3b5d2c12b0b8f7a6b-')).toBe(false); + expect(isObjectID(undefined)).toBe(false); + }); + }); +}); diff --git a/api/src/utils/validation.ts b/api/src/utils/validation.ts new file mode 100644 index 00000000000..f4c9984bcc0 --- /dev/null +++ b/api/src/utils/validation.ts @@ -0,0 +1,6 @@ +import { ObjectId } from 'mongodb'; + +// This is trivial, but makes it simple to refactor if we swap monogodb for +// bson, say. +export const isObjectID = (id?: string): boolean => + id ? ObjectId.isValid(id) : false; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c84751c425..3e4f1fa600b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,12 +195,21 @@ importers: '@prisma/client': specifier: 4.16.2 version: 4.16.2(prisma@4.16.2) + ajv: + specifier: 8.12.0 + version: 8.12.0 + ajv-formats: + specifier: ^2.1.1 + version: 2.1.1(ajv@8.12.0) bad-words: specifier: 3.0.4 version: 3.0.4 connect-mongo: specifier: 4.6.0 version: 4.6.0(express-session@1.17.3)(mongodb@4.16.0) + fast-uri: + specifier: 2.2.0 + version: 2.2.0 fastify: specifier: 4.19.2 version: 4.19.2 @@ -214,7 +223,7 @@ importers: specifier: 9.0.1 version: 9.0.1 mongodb: - specifier: '4' + specifier: ^4.16.0 version: 4.16.0 nanoid: specifier: '3' @@ -241,9 +250,6 @@ importers: '@types/supertest': specifier: 2.0.12 version: 2.0.12 - ajv: - specifier: 8.12.0 - version: 8.12.0 dotenv-cli: specifier: 7.2.1 version: 7.2.1 @@ -20767,23 +20773,13 @@ packages: dependencies: '@types/express': 4.17.17 '@types/http-proxy': 1.17.10 - http-proxy: 1.18.1 + http-proxy: 1.18.1(debug@3.2.7) is-glob: 4.0.3 is-plain-obj: 3.0.0 micromatch: 4.0.5 transitivePeerDependencies: - debug - /http-proxy@1.18.1: - resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} - engines: {node: '>=8.0.0'} - dependencies: - eventemitter3: 4.0.7 - follow-redirects: 1.15.2(debug@4.3.4) - requires-port: 1.0.0 - transitivePeerDependencies: - - debug - /http-proxy@1.18.1(debug@3.2.7): resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} engines: {node: '>=8.0.0'}