mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-04-29 16:00:55 -04:00
feat(api): add DELETE "/account" endpoint to API (#61745)
Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user