diff --git a/api/jest.utils.ts b/api/jest.utils.ts index ff5a88a5563..e562fd95282 100644 --- a/api/jest.utils.ts +++ b/api/jest.utils.ts @@ -18,7 +18,9 @@ const requests = { GET: (resource: string) => request(fastifyTestInstance?.server).get(resource), POST: (resource: string) => request(fastifyTestInstance?.server).post(resource), - PUT: (resource: string) => request(fastifyTestInstance?.server).put(resource) + PUT: (resource: string) => request(fastifyTestInstance?.server).put(resource), + DELETE: (resource: string) => + request(fastifyTestInstance?.server).delete(resource) }; /* eslint-enable @typescript-eslint/naming-convention */ @@ -33,7 +35,7 @@ export const getCsrfToken = (setCookies: string[]): string | undefined => { export function superRequest( resource: string, config: { - method: 'GET' | 'POST' | 'PUT'; + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; setCookies?: string[]; }, options?: Options diff --git a/api/src/app.ts b/api/src/app.ts index 15ae8dc1743..721315a9a40 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -39,6 +39,7 @@ import { FCC_ENABLE_DEV_LOGIN_MODE, SENTRY_DSN } from './utils/env'; +import { userRoutes } from './routes/user'; export type FastifyInstanceWithTypeProvider = FastifyInstance< RawServerDefault, @@ -165,6 +166,7 @@ export const build = async ( void fastify.register(devLoginCallback, { prefix: '/auth' }); } void fastify.register(settingRoutes); + void fastify.register(userRoutes, { prefix: '/user' }); void fastify.register(deprecatedEndpoints); return fastify; diff --git a/api/src/db/prisma.ts b/api/src/db/prisma.ts index 8a2728abdcf..dd0d9d8ce85 100644 --- a/api/src/db/prisma.ts +++ b/api/src/db/prisma.ts @@ -2,14 +2,32 @@ import fp from 'fastify-plugin'; import { FastifyPluginAsync } from 'fastify'; import { PrismaClient } from '@prisma/client'; +import { FREECODECAMP_NODE_ENV, MONGOHQ_URL } from '../utils/env'; + declare module 'fastify' { interface FastifyInstance { prisma: PrismaClient; } } +function createTestConnectionURL(url: string, dbId: string) { + return url.replace(/(.*\/)(.*)(\?.*)/, `$1$2${dbId}$3`); +} + const prismaPlugin: FastifyPluginAsync = fp(async (server, _options) => { - const prisma = new PrismaClient(); + const prisma = + process.env.JEST_WORKER_ID && FREECODECAMP_NODE_ENV === 'development' + ? new PrismaClient({ + datasources: { + db: { + url: createTestConnectionURL( + MONGOHQ_URL, + process.env.JEST_WORKER_ID + ) + } + } + }) + : new PrismaClient(); await prisma.$connect(); diff --git a/api/src/routes/user.test.ts b/api/src/routes/user.test.ts new file mode 100644 index 00000000000..730112b91c6 --- /dev/null +++ b/api/src/routes/user.test.ts @@ -0,0 +1,48 @@ +import request from 'supertest'; + +import { setupServer, superRequest } from '../../jest.utils'; + +describe('userRoutes', () => { + setupServer(); + + describe('Authenticated user', () => { + let setCookies: string[]; + + beforeAll(async () => { + const res = await request(fastifyTestInstance?.server).get( + '/auth/dev-callback' + ); + setCookies = res.get('Set-Cookie'); + }); + + describe('/account', () => { + test('DELETE returns 200 status code with empty object', async () => { + const response = await superRequest('/user/account', { + method: 'DELETE', + setCookies + }); + + const userCount = await fastifyTestInstance?.prisma.user.count({ + where: { email: 'foo@bar.com' } + }); + + expect(response.status).toBe(200); + expect(response.body).toStrictEqual({}); + expect(userCount).toBe(0); + }); + }); + }); + + describe('Unauthenticated user', () => { + // TODO: get CSRF cookies when that PR is in. + describe('/account', () => { + test('DELETE returns 401 status code with error message', async () => { + const response = await superRequest('/user/account', { + method: 'DELETE' + }); + + expect(response?.statusCode).toBe(401); + }); + }); + }); +}); diff --git a/api/src/routes/user.ts b/api/src/routes/user.ts new file mode 100644 index 00000000000..b4a4fe24428 --- /dev/null +++ b/api/src/routes/user.ts @@ -0,0 +1,53 @@ +import { + Type, + type FastifyPluginCallbackTypebox +} from '@fastify/type-provider-typebox'; + +export const userRoutes: FastifyPluginCallbackTypebox = ( + fastify, + _options, + done +) => { + fastify.addHook('onRequest', fastify.authenticateSession); + + fastify.delete( + '/account', + { + schema: { + 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') + }) + } + } + }, + async (req, reply) => { + try { + await fastify.prisma.userToken.deleteMany({ + where: { userId: req.session.user.id } + }); + await fastify.prisma.user.delete({ + where: { id: req.session.user.id } + }); + await req.session.destroy(); + void reply.clearCookie('sessionId'); + + return {}; + } catch (err) { + 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' + }; + } + } + ); + + done(); +};