From 7cbd1d79b9c68267ee7fed1f77aed681b8ee2359 Mon Sep 17 00:00:00 2001 From: Sem Bauke Date: Sun, 12 Oct 2025 17:59:42 +0200 Subject: [PATCH] feat(api): add DELETE "/account" endpoint to API (#61745) Co-authored-by: Shaun Hamilton --- api/src/routes/protected/user.test.ts | 167 ++++++++++++++++++++-- api/src/routes/protected/user.ts | 54 +++++++ api/src/schemas.ts | 5 +- api/src/schemas/user/delete-my-account.ts | 13 ++ 4 files changed, 227 insertions(+), 12 deletions(-) diff --git a/api/src/routes/protected/user.test.ts b/api/src/routes/protected/user.test.ts index c008f19d11c..7f98c0f4bdc 100644 --- a/api/src/routes/protected/user.test.ts +++ b/api/src/routes/protected/user.test.ts @@ -416,15 +416,17 @@ describe('userRoutes', () => { }); test('POST returns 200 status code with empty object', async () => { - expect(await fastifyTestInstance.prisma.user.count()).toBe(1); + const initialCount = await fastifyTestInstance.prisma.user.count(); const response = await superPost('/account/delete'); - const userCount = await fastifyTestInstance.prisma.user.count({ + const finalCount = await fastifyTestInstance.prisma.user.count(); + const deletedUser = await fastifyTestInstance.prisma.user.findFirst({ where: { email: testUserData.email } }); expect(response.body).toStrictEqual({}); expect(response.status).toBe(200); - expect(userCount).toBe(0); + expect(finalCount).toBe(initialCount - 1); + expect(deletedUser).toBeNull(); }); test('POST deletes Microsoft usernames associated with the user', async () => { @@ -516,18 +518,26 @@ describe('userRoutes', () => { }); test("only deletes the logged in user's data", async () => { - await fastifyTestInstance.prisma.user.create({ + const initialCount = await fastifyTestInstance.prisma.user.count(); + const otherEmail = 'an.random@user'; + const otherUser = await fastifyTestInstance.prisma.user.create({ data: { ...testUserData, - email: 'an.random@user' + email: otherEmail } }); - expect(await fastifyTestInstance.prisma.user.count()).toBe(2); + expect(otherUser.email).toBe(otherEmail); + const afterAdd = await fastifyTestInstance.prisma.user.count(); + expect(afterAdd).toBe(initialCount + 1); await superPost('/account/delete'); - const userCount = await fastifyTestInstance.prisma.user.count(); - expect(userCount).toBe(1); + const finalCount = await fastifyTestInstance.prisma.user.count(); + expect(finalCount).toBe(initialCount); + const remaining = await fastifyTestInstance.prisma.user.findFirst({ + where: { email: otherEmail } + }); + expect(remaining).not.toBeNull(); }); test('logs if it is asked to delete a non-existent user', async () => { @@ -540,13 +550,147 @@ describe('userRoutes', () => { superPost('/account/delete') ); await Promise.all(deletePromises); + const messages: string[] = spy.mock.calls.map(call => + call.map(part => String(part)).join(' ') + ); + const found = messages.some(m => + m.includes(`User with id ${defaultUserId} not found for deletion.`) + ); + expect(found).toBe(true); + }); + }); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy.mock.calls[0]).toEqual( + describe('/users/:userId', () => { + afterEach(async () => { + await fastifyTestInstance.prisma.userToken.deleteMany({ + where: { OR: [{ userId: defaultUserId }, { userId: otherUserId }] } + }); + await fastifyTestInstance.prisma.msUsername.deleteMany({ + where: { OR: [{ userId: defaultUserId }, { userId: otherUserId }] } + }); + await clearEnvExam(); + }); + + test('DELETE returns 204 status code with empty object', async () => { + const response = await superDelete(`/users/${defaultUserId}`); + const userCount = await fastifyTestInstance.prisma.user.count({ + where: { email: testUserData.email } + }); + + expect(response.body).toStrictEqual({}); + expect(response.status).toBe(204); + expect(userCount).toBe(0); + }); + + test('DELETE deletes Microsoft usernames associated with the user', async () => { + await fastifyTestInstance.prisma.msUsername.createMany({ + data: msUsernameData + }); + + await superDelete(`/users/${defaultUserId}`); + expect(await fastifyTestInstance.prisma.msUsername.count()).toBe(1); + }); + + test('DELETE deletes userTokens associated with the user', async () => { + await fastifyTestInstance.prisma.userToken.createMany({ + data: tokenData + }); + + await superDelete(`/users/${defaultUserId}`); + + const userTokens = + await fastifyTestInstance.prisma.userToken.findMany(); + expect(userTokens).toHaveLength(1); + expect(userTokens[0]?.userId).toBe(otherUserId); + }); + + test("DELETE deletes all the user's cookies", async () => { + const res = await superDelete(`/users/${defaultUserId}`); + + const setCookie = res.headers['set-cookie'] as string[]; + expect(setCookie).toEqual( expect.arrayContaining([ - `User with id ${defaultUserId} not found for deletion.` + expect.stringMatching( + /^_csrf=; Max-Age=0; Path=\/:?; Expires=Thu, 01 Jan 1970 00:00:00 GMT/ + ), + expect.stringMatching( + /^csrf_token=; Max-Age=0; Path=\/:?; Expires=Thu, 01 Jan 1970 00:00:00 GMT/ + ), + expect.stringMatching( + /^jwt_access_token=; Max-Age=0; Path=\/:?; Expires=Thu, 01 Jan 1970 00:00:00 GMT/ + ) ]) ); + expect(setCookie).toHaveLength(3); + }); + + test("DELETE deletes all the user's exam attempts", async () => { + await seedEnvExam(); + await seedEnvExamAttempt(); + const countBefore = + await fastifyTestInstance.prisma.examEnvironmentExamAttempt.count(); + expect(countBefore).toBe(1); + + const res = await superDelete(`/users/${defaultUserId}`); + + const countAfter = + await fastifyTestInstance.prisma.examEnvironmentExamAttempt.count(); + expect(countAfter).toBe(0); + expect(res.status).toBe(204); + }); + + test("DELETE deletes all the user's exam tokens", async () => { + await seedExamEnvExamAuthToken(); + const countBefore = + await fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.count(); + expect(countBefore).toBe(1); + + const res = await superDelete(`/users/${defaultUserId}`); + + const countAfter = + await fastifyTestInstance.prisma.examEnvironmentAuthorizationToken.count(); + expect(countAfter).toBe(0); + expect(res.status).toBe(204); + }); + + test("only deletes the logged in user's data", async () => { + const initialCount = await fastifyTestInstance.prisma.user.count(); + await fastifyTestInstance.prisma.user.create({ + data: { + ...testUserData, + email: 'an.random@user' + } + }); + expect(await fastifyTestInstance.prisma.user.count()).toBe( + initialCount + 1 + ); + + await superDelete(`/users/${defaultUserId}`); + + const userCount = await fastifyTestInstance.prisma.user.count(); + expect(userCount).toBe(initialCount); + }); + + test('logs if it is asked to delete a non-existent user', async () => { + const spy = vi.spyOn(fastifyTestInstance.log, 'warn'); + + const deletePromises = Array.from({ length: 2 }, () => + superDelete(`/users/${defaultUserId}`) + ); + + await Promise.all(deletePromises); + + const messages = spy.mock.calls.flat().map(String); + expect( + messages.some(m => + m.includes(`User with id ${defaultUserId} not found for deletion.`) + ) + ).toBe(true); + }); + + test('returns 403 if attempting to delete a different user', async () => { + const res = await superDelete(`/users/${otherUserId}`); + expect(res.status).toBe(403); }); }); @@ -1427,6 +1571,7 @@ Thanks and regards, }); const endpoints: { path: string; method: 'GET' | 'POST' | 'DELETE' }[] = [ + { path: `/users/${otherUserId}`, method: 'DELETE' }, { path: '/account/delete', method: 'POST' }, { path: '/account/reset-progress', method: 'POST' }, { path: '/user/get-session-user', method: 'GET' }, diff --git a/api/src/routes/protected/user.ts b/api/src/routes/protected/user.ts index 5e63fe54a75..253980f2268 100644 --- a/api/src/routes/protected/user.ts +++ b/api/src/routes/protected/user.ts @@ -116,6 +116,60 @@ export const userRoutes: FastifyPluginCallbackTypebox = ( } ); + fastify.delete( + '/users/:userId', + { + schema: schemas.deleteUser + }, + async (req, reply) => { + const logger = fastify.log.child({ req, res: reply }); + const { userId } = req.params; + + if (userId !== req.user?.id) { + logger.warn( + { requestedUserId: userId, authUserId: req.user?.id }, + 'User attempted to delete an account they do not have authorization to.' + ); + void reply.code(403); + return { type: 'error', message: 'forbidden' } as const; + } + + logger.info(`User ${req.user.id} requested account deletion`); + try { + await fastify.prisma.userToken.deleteMany({ + where: { userId: req.user.id } + }); + await fastify.prisma.msUsername.deleteMany({ + where: { userId: req.user.id } + }); + await fastify.prisma.survey.deleteMany({ + where: { userId: req.user.id } + }); + await fastify.prisma.user.delete({ + where: { id: req.user.id } + }); + } catch (err) { + // Whilst this is behind auth, this should never happen + if ( + err instanceof PrismaClientKnownRequestError && + err.code === 'P2025' + ) { + logger.warn( + err, + `User with id ${req.user?.id} not found for deletion.` + ); + return reply.code(404).send({ type: 'error', message: 'not found' }); + } else { + logger.error(err, 'Error deleting user account'); + throw err; + } + } + reply.clearOurCookies(); + + return reply.code(204).send(); + } + ); + fastify.post( '/account/reset-progress', { diff --git a/api/src/schemas.ts b/api/src/schemas.ts index 0005ee69681..df5af894edc 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -34,7 +34,10 @@ export { updateMySocials } from './schemas/settings/update-my-socials.js'; export { updateMyTheme } from './schemas/settings/update-my-theme.js'; export { updateMyUsername } from './schemas/settings/update-my-username.js'; export { deleteMsUsername } from './schemas/user/delete-ms-username.js'; -export { deleteMyAccount } from './schemas/user/delete-my-account.js'; +export { + deleteMyAccount, + deleteUser +} from './schemas/user/delete-my-account.js'; export { deleteUserToken } from './schemas/user/delete-user-token.js'; export { getSessionUser } from './schemas/user/get-session-user.js'; export { postMsUsername } from './schemas/user/post-ms-username.js'; diff --git a/api/src/schemas/user/delete-my-account.ts b/api/src/schemas/user/delete-my-account.ts index 4a978d8d5f2..0e11760c117 100644 --- a/api/src/schemas/user/delete-my-account.ts +++ b/api/src/schemas/user/delete-my-account.ts @@ -7,3 +7,16 @@ export const deleteMyAccount = { default: genericError } }; + +export const deleteUser = { + params: Type.Object({ + userId: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }) + }), + response: { + 204: Type.Null(), + default: Type.Object({ + type: Type.String(), + message: Type.String() + }) + } +};