feat(api): add DELETE "/account" endpoint to API (#61745)

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
Sem Bauke
2025-10-12 17:59:42 +02:00
committed by GitHub
parent 7709d3d786
commit 7cbd1d79b9
4 changed files with 227 additions and 12 deletions

View File

@@ -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' },

View File

@@ -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',
{

View File

@@ -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';

View File

@@ -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()
})
}
};