From 77d56c2cbbcda110e8f299b3b474d00fc59c0eaa Mon Sep 17 00:00:00 2001 From: yugalkaushik Date: Sun, 8 Mar 2026 04:16:59 +0530 Subject: [PATCH 1/5] add account deletion --- client/modules/User/actions.ts | 31 +++++ client/modules/User/components/DangerZone.tsx | 113 ++++++++++++++++++ client/modules/User/pages/AccountView.tsx | 9 +- client/styles/components/_account.scss | 32 ++++- common/types/index.ts | 1 + .../user.controller/authManagement.ts | 83 +++++++++++++ server/routes/user.routes.ts | 2 + server/types/user.ts | 4 + translations/locales/en-US/translations.json | 11 ++ 9 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 client/modules/User/components/DangerZone.tsx diff --git a/client/modules/User/actions.ts b/client/modules/User/actions.ts index b34c2bd1b4..40f6697e7f 100644 --- a/client/modules/User/actions.ts +++ b/client/modules/User/actions.ts @@ -10,6 +10,7 @@ import { showToast, setToastText } from '../IDE/actions/toast'; import type { CreateApiKeyRequestBody, CreateUserRequestBody, + DeleteAccountRequestBody, Error, PublicUser, PublicUserOrError, @@ -506,3 +507,33 @@ export function setUserCookieConsent( }); }; } + +/** + * - Method: `DELETE` + * - Endpoint: `/account` + * - Authenticated: `true` + * - Id: `UserController.deleteAccount` + * + * Description: + * - Permanently delete the authenticated user's account and all their data. + * - Returns the error message from the server on failure, or redirects to `/` on success. + */ +export function deleteAccount(formValues: DeleteAccountRequestBody) { + return (dispatch: Dispatch) => + new Promise((resolve) => { + apiClient + .delete('/account', { data: formValues }) + .then(() => { + dispatch({ type: ActionTypes.UNAUTH_USER }); + dispatch({ type: ActionTypes.RESET_PROJECT }); + dispatch({ type: ActionTypes.CLEAR_CONSOLE }); + browserHistory.push('/'); + resolve(); + }) + .catch((error) => { + const message = + error.response?.data?.message || 'Error deleting account.'; + resolve(message); + }); + }); +} diff --git a/client/modules/User/components/DangerZone.tsx b/client/modules/User/components/DangerZone.tsx new file mode 100644 index 0000000000..c14114be44 --- /dev/null +++ b/client/modules/User/components/DangerZone.tsx @@ -0,0 +1,113 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import type { ThunkDispatch } from 'redux-thunk'; +import type { AnyAction } from 'redux'; +import { Button, ButtonKinds, ButtonTypes } from '../../../common/Button'; +import { deleteAccount } from '../actions'; +import { RootState } from '../../../reducers'; + +export function DangerZone() { + const { t } = useTranslation(); + const dispatch = useDispatch>(); + const hasPassword = useSelector( + (state: RootState) => + state.user.github === undefined && state.user.google === undefined + ); + + const [isConfirming, setIsConfirming] = useState(false); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleDelete = async (event: React.FormEvent) => { + event.preventDefault(); + setError(''); + setIsSubmitting(true); + const result = await dispatch( + deleteAccount(hasPassword ? { password } : {}) + ); + setIsSubmitting(false); + if (result) { + setError(result as string); + } + }; + + const header = ( + +

{t('DangerZone.Title')}

+

+ {t('DangerZone.DeleteAccountDescription')} +

+
+ ); + + if (!isConfirming) { + return ( +
+ {header} +
+ +
+
+ ); + } + + return ( +
+ {header} +
+ {hasPassword && ( +

+ + setPassword(e.target.value)} + /> +

+ )} + {error && ( +

+ {error} +

+ )} +
+ + +
+
+
+ ); +} diff --git a/client/modules/User/pages/AccountView.tsx b/client/modules/User/pages/AccountView.tsx index 8c32071888..3912bd1b46 100644 --- a/client/modules/User/pages/AccountView.tsx +++ b/client/modules/User/pages/AccountView.tsx @@ -11,6 +11,7 @@ import { SocialAuthServices } from '../components/SocialAuthButton'; import { APIKeyForm } from '../components/APIKeyForm'; +import { DangerZone } from '../components/DangerZone'; import Nav from '../../IDE/components/Header/Nav'; import ErrorModal from '../../IDE/components/ErrorModal'; import { hideErrorModal } from '../../IDE/actions/ide'; @@ -106,13 +107,19 @@ export function AccountView() { + )} - {!accessTokensUIEnabled && } + {!accessTokensUIEnabled && ( + + + + + )} ); diff --git a/client/styles/components/_account.scss b/client/styles/components/_account.scss index 469be83497..4bd7d60a26 100644 --- a/client/styles/components/_account.scss +++ b/client/styles/components/_account.scss @@ -29,7 +29,6 @@ padding-bottom: #{math.div(15, $base-font-size)}rem; } - .account__social-stack { display: flex; @media (max-width: 770px) { @@ -37,7 +36,8 @@ align-items: center; gap: #{math.div(15, $base-font-size)}rem; - button, a { + button, + a { width: 100% !important; margin-right: 0; } @@ -47,3 +47,31 @@ .account__social-stack > * { margin-right: #{math.div(15, $base-font-size)}rem; } + +.account__inline-label { + font-size: #{math.div(12, $base-font-size)}rem; + margin-top: #{math.div(10, $base-font-size)}rem; + margin-bottom: #{math.div(7, $base-font-size)}rem; + display: block; + @include themify() { + color: getThemifyVariable('form-secondary-title-color'); + } +} + +.account__danger-zone { + padding-bottom: #{math.div(40, $base-font-size)}rem; +} + +.account__action-stack { + display: flex; + justify-content: flex-start; + gap: #{math.div(15, $base-font-size)}rem; + @media (max-width: 770px) { + flex-direction: column; + align-items: stretch; + + button { + width: 100% !important; + } + } +} diff --git a/common/types/index.ts b/common/types/index.ts index b45c30b26e..13f8831746 100644 --- a/common/types/index.ts +++ b/common/types/index.ts @@ -26,6 +26,7 @@ export type { ResetOrUpdatePasswordRequestParams, UpdatePasswordRequestBody, CreateUserRequestBody, + DeleteAccountRequestBody, DuplicateUserCheckQuery, VerifyEmailQuery } from '../../server/types/user'; diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts index 19f3cf870c..4a9ada8a48 100644 --- a/server/controllers/user.controller/authManagement.ts +++ b/server/controllers/user.controller/authManagement.ts @@ -1,7 +1,10 @@ import { RequestHandler } from 'express'; +import Project from '../../models/project'; +import Collection from '../../models/collection'; import { User } from '../../models/user'; import { saveUser, generateToken, userResponse } from './helpers'; import { + DeleteAccountRequestBody, GenericResponseBody, PublicUserOrErrorOrGeneric, UnlinkThirdPartyResponseBody, @@ -13,6 +16,7 @@ import { } from '../../types'; import { mailerService } from '../../utils/mail'; import { renderResetPassword, renderEmailConfirmation } from '../../views/mail'; +import { deleteObjectsFromS3, getObjectKey } from '../aws.controller'; /** * - Method: `POST` @@ -258,3 +262,82 @@ export const unlinkGoogle: RequestHandler< message: 'You must be logged in to complete this action.' }); }; + +/** + * - Method: `DELETE` + * - Endpoint: `/account` + * - Authenticated: `true` + * - Id: `UserController.deleteAccount` + * + * Description: + * - Permanently delete the authenticated user's account, all their projects + * (including S3 assets) and all their collections. + * - Users with a password must supply it in the request body for confirmation. + */ +export const deleteAccount: RequestHandler< + {}, + GenericResponseBody, + DeleteAccountRequestBody +> = async (req, res) => { + try { + const user = await User.findById(req.user!.id); + if (!user) { + res.status(404).json({ success: false, message: 'User not found.' }); + return; + } + + if (user.password) { + if (!req.body.password) { + res + .status(401) + .json({ success: false, message: 'Password is required.' }); + return; + } + const isMatch = await user.comparePassword(req.body.password); + if (!isMatch) { + res.status(401).json({ success: false, message: 'Invalid password.' }); + return; + } + } + + const projects = await Project.find({ user: user._id }).exec(); + + const s3Keys = projects.flatMap((project: any) => + (project.files as any[]) + .filter( + (file: any) => + file.url && + (file.url.includes(process.env.S3_BUCKET_URL_BASE || '') || + file.url.includes(process.env.S3_BUCKET || '')) + ) + .map((file: any) => getObjectKey(file.url)) + ); + + if (s3Keys.length > 0) { + try { + await deleteObjectsFromS3(s3Keys); + } catch (err) { + console.error( + 'Failed to delete S3 assets during account deletion', + err + ); + } + } + + await Project.deleteMany({ user: user._id }).exec(); + await Collection.deleteMany({ owner: user._id }).exec(); + + req.logout((logoutErr) => { + if (logoutErr) { + console.error('Error during logout on account deletion', logoutErr); + } + (req as any).session?.destroy(() => {}); + }); + + await user.deleteOne(); + + res.json({ success: true, message: 'Account successfully deleted.' }); + } catch (err) { + res.status(500).json({ success: false, message: 'Internal server error.' }); + } +}; diff --git a/server/routes/user.routes.ts b/server/routes/user.routes.ts index a61a42e11b..f4adfce929 100644 --- a/server/routes/user.routes.ts +++ b/server/routes/user.routes.ts @@ -45,6 +45,8 @@ router.get('/reset-password/:token', UserController.validateResetPasswordToken); router.post('/reset-password/:token', UserController.updatePassword); // PUT /account (updating username, email or password while logged in) router.put('/account', isAuthenticated, UserController.updateSettings); +// DELETE /account (delete user account) +router.delete('/account', isAuthenticated, UserController.deleteAccount); // DELETE /auth/github router.delete('/auth/github', UserController.unlinkGithub); // DELETE /auth/google diff --git a/server/types/user.ts b/server/types/user.ts index 1f180fee37..d2a596683b 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -130,6 +130,10 @@ export interface CreateUserRequestBody { email: string; password: string; } +/** userController.deleteAccount - Request */ +export interface DeleteAccountRequestBody { + password?: string; +} /** userController.duplicateUserCheck - Query */ export interface DuplicateUserCheckQuery { // eslint-disable-next-line camelcase diff --git a/translations/locales/en-US/translations.json b/translations/locales/en-US/translations.json index 7529a1d9ff..f319d4e260 100644 --- a/translations/locales/en-US/translations.json +++ b/translations/locales/en-US/translations.json @@ -721,5 +721,16 @@ "Label": "Private" }, "Changed": "'{{projectName}}' is now {{newVisibility}}..." + }, + "DangerZone": { + "Title": "Danger Zone", + "DeleteAccount": "Delete Account", + "DeleteAccountDescription": "Permanently delete your account and all associated sketches, collections, and assets. This action cannot be undone.", + "PasswordLabel": "Confirm your password", + "PasswordARIA": "Password", + "Confirm": "Permanently Delete Account", + "Cancel": "Cancel", + "InvalidPassword": "Invalid password.", + "PasswordRequired": "Password is required." } } From cda2bffaacd5b8722dc033d77bcf6450297e2926 Mon Sep 17 00:00:00 2001 From: yugalkaushik Date: Sun, 8 Mar 2026 04:40:45 +0530 Subject: [PATCH 2/5] quick fix --- client/styles/components/_account.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/styles/components/_account.scss b/client/styles/components/_account.scss index 4bd7d60a26..e0eafba68a 100644 --- a/client/styles/components/_account.scss +++ b/client/styles/components/_account.scss @@ -29,6 +29,7 @@ padding-bottom: #{math.div(15, $base-font-size)}rem; } + .account__social-stack { display: flex; @media (max-width: 770px) { @@ -36,8 +37,7 @@ align-items: center; gap: #{math.div(15, $base-font-size)}rem; - button, - a { + button, a { width: 100% !important; margin-right: 0; } From cf483964f4d5d2b78b5e19dd94b9c52de9bb3823 Mon Sep 17 00:00:00 2001 From: yugalkaushik Date: Tue, 10 Mar 2026 13:23:25 +0530 Subject: [PATCH 3/5] added cleanup --- server/controllers/user.controller/authManagement.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts index 4a9ada8a48..a1935d2677 100644 --- a/server/controllers/user.controller/authManagement.ts +++ b/server/controllers/user.controller/authManagement.ts @@ -300,6 +300,12 @@ export const deleteAccount: RequestHandler< } } + user.github = undefined; + user.google = undefined; + user.tokens = user.tokens.filter( + (token) => token.kind !== 'github' && token.kind !== 'google' + ); + const projects = await Project.find({ user: user._id }).exec(); const s3Keys = projects.flatMap((project: any) => From d5e6366e60e97ae41e078e5bfb9affffd402dce6 Mon Sep 17 00:00:00 2001 From: yugalkaushik Date: Tue, 10 Mar 2026 23:19:33 +0530 Subject: [PATCH 4/5] fixes --- server/controllers/aws.controller.js | 20 +++++++++++++ .../user.controller/authManagement.ts | 28 ++++--------------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/server/controllers/aws.controller.js b/server/controllers/aws.controller.js index f321c40ffa..2479796fa2 100644 --- a/server/controllers/aws.controller.js +++ b/server/controllers/aws.controller.js @@ -181,6 +181,26 @@ export async function moveObjectToUserInS3(url, userId) { return `${s3Bucket}${userId}/${newFilename}`; } +export async function deleteAllObjectsForUser(userId) { + try { + const params = { + Bucket: process.env.S3_BUCKET, + Prefix: `${userId}/` + }; + const data = await s3Client.send(new ListObjectsCommand(params)); + const keys = (data.Contents || []).map((object) => object.Key); + if (keys.length > 0) { + await deleteObjectsFromS3(keys); + } + } catch (error) { + if (error instanceof TypeError) { + return; + } + console.error('Error deleting all S3 objects for user: ', error); + throw error; + } +} + export async function listObjectsInS3ForUser(userId) { try { let assets = []; diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts index a1935d2677..6dc8098737 100644 --- a/server/controllers/user.controller/authManagement.ts +++ b/server/controllers/user.controller/authManagement.ts @@ -16,7 +16,7 @@ import { } from '../../types'; import { mailerService } from '../../utils/mail'; import { renderResetPassword, renderEmailConfirmation } from '../../views/mail'; -import { deleteObjectsFromS3, getObjectKey } from '../aws.controller'; +import { deleteAllObjectsForUser } from '../aws.controller'; /** * - Method: `POST` @@ -306,28 +306,10 @@ export const deleteAccount: RequestHandler< (token) => token.kind !== 'github' && token.kind !== 'google' ); - const projects = await Project.find({ user: user._id }).exec(); - - const s3Keys = projects.flatMap((project: any) => - (project.files as any[]) - .filter( - (file: any) => - file.url && - (file.url.includes(process.env.S3_BUCKET_URL_BASE || '') || - file.url.includes(process.env.S3_BUCKET || '')) - ) - .map((file: any) => getObjectKey(file.url)) - ); - - if (s3Keys.length > 0) { - try { - await deleteObjectsFromS3(s3Keys); - } catch (err) { - console.error( - 'Failed to delete S3 assets during account deletion', - err - ); - } + try { + await deleteAllObjectsForUser(user._id.toString()); + } catch (err) { + console.error('Failed to delete S3 assets during account deletion', err); } await Project.deleteMany({ user: user._id }).exec(); From 0ce6779f467a8a6391d86c490b9c5b56b3d8d5ab Mon Sep 17 00:00:00 2001 From: yugalkaushik Date: Thu, 12 Mar 2026 22:43:07 +0530 Subject: [PATCH 5/5] added tests --- .../User/components/DangerZone.unit.test.jsx | 356 ++++++++++++++++ .../authManagement/deleteAccount.test.ts | 392 ++++++++++++++++++ 2 files changed, 748 insertions(+) create mode 100644 client/modules/User/components/DangerZone.unit.test.jsx create mode 100644 server/controllers/user.controller/__tests__/authManagement/deleteAccount.test.ts diff --git a/client/modules/User/components/DangerZone.unit.test.jsx b/client/modules/User/components/DangerZone.unit.test.jsx new file mode 100644 index 0000000000..78414a2fae --- /dev/null +++ b/client/modules/User/components/DangerZone.unit.test.jsx @@ -0,0 +1,356 @@ +import React from 'react'; +import thunk from 'redux-thunk'; +import configureStore from 'redux-mock-store'; +import { + reduxRender, + screen, + fireEvent, + act, + waitFor +} from '../../../test-utils'; +import { initialTestState } from '../../../testData/testReduxStore'; +import { DangerZone } from './DangerZone'; +import * as actions from '../actions'; + +const mockStore = configureStore([thunk]); + +const mockDispatch = jest.fn(); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch +})); + +jest.mock('../actions', () => ({ + ...jest.requireActual('../actions'), + deleteAccount: jest.fn() +})); + +const storeWithPassword = mockStore({ + ...initialTestState, + user: { + ...initialTestState.user, + github: undefined, + google: undefined + } +}); + +const storeWithSocialOnly = mockStore({ + ...initialTestState, + user: { + ...initialTestState.user, + github: 'gh_user', + google: undefined + } +}); + +const renderWithPassword = () => + reduxRender(, { store: storeWithPassword }); + +const renderWithSocial = () => + reduxRender(, { store: storeWithSocialOnly }); + +describe('', () => { + beforeEach(() => { + mockDispatch.mockClear(); + jest.clearAllMocks(); + }); + + describe('initial render', () => { + it('renders the Danger Zone heading', () => { + renderWithPassword(); + expect(screen.getByText(/danger zone/i)).toBeInTheDocument(); + }); + + it('renders the description text', () => { + renderWithPassword(); + expect( + screen.getByText(/permanently delete your account/i) + ).toBeInTheDocument(); + }); + + it('renders the Delete Account button', () => { + renderWithPassword(); + expect( + screen.getByRole('button', { name: /delete account/i }) + ).toBeInTheDocument(); + }); + + it('does not show the confirmation form initially', () => { + renderWithPassword(); + expect( + screen.queryByRole('button', { name: /permanently delete account/i }) + ).not.toBeInTheDocument(); + }); + }); + + describe('when Delete Account is clicked', () => { + beforeEach(async () => { + renderWithPassword(); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /delete account/i }) + ); + }); + }); + + it('shows the password field for users with a password', () => { + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + }); + + it('shows the Permanently Delete Account confirm button', () => { + expect( + screen.getByRole('button', { name: /permanently delete account/i }) + ).toBeInTheDocument(); + }); + + it('shows the Cancel button', () => { + expect( + screen.getByRole('button', { name: /cancel/i }) + ).toBeInTheDocument(); + }); + + it('confirm button is disabled when password field is empty', () => { + expect( + screen.getByRole('button', { name: /permanently delete account/i }) + ).toBeDisabled(); + }); + + it('confirm button becomes enabled once password is typed', async () => { + fireEvent.change(screen.getByLabelText(/password/i), { + target: { value: 'somepassword' } + }); + expect( + screen.getByRole('button', { name: /permanently delete account/i }) + ).not.toBeDisabled(); + }); + }); + + describe('when user has only social logins (no password)', () => { + beforeEach(async () => { + renderWithSocial(); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /delete account/i }) + ); + }); + }); + + it('does not show a password field', () => { + expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument(); + }); + + it('confirm button is enabled immediately (no password required)', () => { + expect( + screen.getByRole('button', { name: /permanently delete account/i }) + ).not.toBeDisabled(); + }); + + it('calls deleteAccount with an empty password object', async () => { + actions.deleteAccount.mockReturnValue(() => Promise.resolve(undefined)); + mockDispatch.mockImplementation((thunkOrAction) => { + if (typeof thunkOrAction === 'function') { + return thunkOrAction(mockDispatch); + } + return Promise.resolve(); + }); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /permanently delete account/i }) + ); + }); + expect(actions.deleteAccount).toHaveBeenCalledWith({}); + }); + }); + + describe('when Cancel is clicked', () => { + beforeEach(async () => { + renderWithPassword(); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /delete account/i }) + ); + }); + fireEvent.change(screen.getByLabelText(/password/i), { + target: { value: 'mypassword' } + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + }); + }); + + it('returns to the initial view', () => { + expect( + screen.getByRole('button', { name: /delete account/i }) + ).toBeInTheDocument(); + }); + + it('hides the confirm button', () => { + expect( + screen.queryByRole('button', { name: /permanently delete account/i }) + ).not.toBeInTheDocument(); + }); + + it('clears the password field on cancel', async () => { + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /delete account/i }) + ); + }); + expect(screen.getByLabelText(/password/i)).toHaveValue(''); + }); + + it('clears the error message on cancel', async () => { + actions.deleteAccount.mockReturnValue(() => + Promise.resolve('Invalid password.') + ); + mockDispatch.mockImplementation((thunkOrAction) => { + if (typeof thunkOrAction === 'function') { + return thunkOrAction(mockDispatch); + } + return Promise.resolve(); + }); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /delete account/i }) + ); + }); + fireEvent.change(screen.getByLabelText(/password/i), { + target: { value: 'bad' } + }); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /permanently delete account/i }) + ); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + }); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /delete account/i }) + ); + }); + expect(screen.queryByText('Invalid password.')).not.toBeInTheDocument(); + }); + }); + + describe('when confirming deletion succeeds', () => { + beforeEach(async () => { + actions.deleteAccount.mockReturnValue(() => Promise.resolve(undefined)); + mockDispatch.mockImplementation((thunkOrAction) => { + if (typeof thunkOrAction === 'function') { + return thunkOrAction(mockDispatch); + } + return Promise.resolve(); + }); + + renderWithPassword(); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /delete account/i }) + ); + }); + fireEvent.change(screen.getByLabelText(/password/i), { + target: { value: 'correctpassword' } + }); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /permanently delete account/i }) + ); + }); + }); + + it('calls deleteAccount with the entered password', () => { + expect(actions.deleteAccount).toHaveBeenCalledWith({ + password: 'correctpassword' + }); + }); + + it('does not show an error message', () => { + expect( + screen.queryByRole('paragraph', { name: /error/i }) + ).not.toBeInTheDocument(); + }); + }); + + describe('when confirming deletion fails', () => { + const errorMessage = 'Invalid password.'; + + beforeEach(async () => { + actions.deleteAccount.mockReturnValue(() => + Promise.resolve(errorMessage) + ); + mockDispatch.mockImplementation((thunkOrAction) => { + if (typeof thunkOrAction === 'function') { + return thunkOrAction(mockDispatch); + } + return Promise.resolve(); + }); + + renderWithPassword(); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /delete account/i }) + ); + }); + fireEvent.change(screen.getByLabelText(/password/i), { + target: { value: 'wrongpassword' } + }); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /permanently delete account/i }) + ); + }); + }); + + it('displays the error message returned by the server', async () => { + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + }); + + it('calls deleteAccount with the typed password', () => { + expect(actions.deleteAccount).toHaveBeenCalledWith({ + password: 'wrongpassword' + }); + }); + }); + + describe('while submission is in progress', () => { + beforeEach(async () => { + actions.deleteAccount.mockReturnValue(() => new Promise(() => {})); + mockDispatch.mockImplementation((thunkOrAction) => { + if (typeof thunkOrAction === 'function') { + return thunkOrAction(mockDispatch); + } + return Promise.resolve(); + }); + + renderWithPassword(); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: /delete account/i }) + ); + }); + fireEvent.change(screen.getByLabelText(/password/i), { + target: { value: 'somepassword' } + }); + act(() => { + fireEvent.click( + screen.getByRole('button', { name: /permanently delete account/i }) + ); + }); + }); + + it('disables the confirm button while submitting', () => { + expect( + screen.getByRole('button', { name: /permanently delete account/i }) + ).toBeDisabled(); + }); + + it('disables the cancel button while submitting', () => { + expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled(); + }); + }); +}); diff --git a/server/controllers/user.controller/__tests__/authManagement/deleteAccount.test.ts b/server/controllers/user.controller/__tests__/authManagement/deleteAccount.test.ts new file mode 100644 index 0000000000..27119e1e8f --- /dev/null +++ b/server/controllers/user.controller/__tests__/authManagement/deleteAccount.test.ts @@ -0,0 +1,392 @@ +import { Request as MockRequest } from 'jest-express/lib/request'; +import { Response as MockResponse } from 'jest-express/lib/response'; +import { NextFunction as MockNext } from 'jest-express/lib/next'; +import { Request, Response } from 'express'; +import { Types } from 'mongoose'; +import { User } from '../../../../models/user'; +import Project from '../../../../models/project'; +import Collection from '../../../../models/collection'; +import { deleteAccount } from '../../authManagement'; +import { deleteAllObjectsForUser } from '../../../aws.controller'; +import { createMockUser } from '../../__testUtils__'; +import { UserDocument } from '../../../../types'; + +const mockObjectId = new Types.ObjectId('507f1f77bcf86cd799439011'); + +jest.mock('../../../../models/user'); +jest.mock('../../../../models/project', () => ({ + __esModule: true, + default: { + find: jest.fn(), + deleteMany: jest.fn() + } +})); +jest.mock('../../../../models/collection', () => ({ + __esModule: true, + default: { + deleteMany: jest.fn() + } +})); +jest.mock('../../../../utils/mail'); +jest.mock('../../../../views/mail'); +jest.mock('../../../aws.controller', () => ({ + deleteAllObjectsForUser: jest.fn().mockResolvedValue(undefined) +})); + +describe('user.controller > auth management > deleteAccount', () => { + let request: MockRequest; + let response: MockResponse; + let next: MockNext; + + beforeEach(() => { + request = new MockRequest(); + response = new MockResponse(); + next = jest.fn(); + (Project.find as jest.Mock).mockReturnValue({ + exec: jest.fn().mockResolvedValue([]) + }); + (Project.deleteMany as jest.Mock).mockReturnValue({ + exec: jest.fn().mockResolvedValue(null) + }); + (Collection.deleteMany as jest.Mock).mockReturnValue({ + exec: jest.fn().mockResolvedValue(null) + }); + }); + + afterEach(() => { + request.resetMocked(); + response.resetMocked(); + jest.clearAllMocks(); + }); + + describe('when the user is not found', () => { + beforeEach(async () => { + request.user = createMockUser({ id: 'nonexistent' }, true); + User.findById = jest.fn().mockResolvedValue(null); + await deleteAccount( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); + }); + it('returns 404', () => { + expect(response.status).toHaveBeenCalledWith(404); + }); + it('returns the correct message', () => { + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'User not found.' + }); + }); + }); + + describe('when the user has a password', () => { + let mockUser: UserDocument; + + beforeEach(() => { + mockUser = createMockUser( + { + _id: mockObjectId, + password: 'hashed', + github: undefined, + google: undefined, + tokens: [], + comparePassword: jest.fn(), + deleteOne: jest.fn().mockResolvedValue(null) + }, + true + ) as UserDocument; + User.findById = jest.fn().mockResolvedValue(mockUser); + }); + + describe('and no password is supplied', () => { + beforeEach(async () => { + request.user = mockUser; + request.body = {}; + await deleteAccount( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); + }); + it('returns 401', () => { + expect(response.status).toHaveBeenCalledWith(401); + }); + it('returns the correct message', () => { + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'Password is required.' + }); + }); + }); + + describe('and an incorrect password is supplied', () => { + beforeEach(async () => { + (mockUser.comparePassword as jest.Mock).mockResolvedValue(false); + request.user = mockUser; + request.body = { password: 'wrongpassword' }; + await deleteAccount( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); + }); + it('returns 401', () => { + expect(response.status).toHaveBeenCalledWith(401); + }); + it('returns the correct message', () => { + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'Invalid password.' + }); + }); + }); + + describe('and the correct password is supplied', () => { + beforeEach(async () => { + (mockUser.comparePassword as jest.Mock).mockResolvedValue(true); + request.user = mockUser; + request.body = { password: 'correctpassword' }; + (request as any).logout = jest.fn((cb: (err: null) => void) => + cb(null) + ); + (request as any).session = { + destroy: jest.fn((cb) => cb && cb()) + }; + await deleteAccount( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); + }); + it('deletes all S3 assets for the user', () => { + expect(deleteAllObjectsForUser).toHaveBeenCalledWith( + mockObjectId.toString() + ); + }); + it('deletes the users projects', () => { + expect(Project.deleteMany).toHaveBeenCalledWith({ user: mockObjectId }); + }); + it('deletes the users collections', () => { + expect(Collection.deleteMany).toHaveBeenCalledWith({ + owner: mockObjectId + }); + }); + it('deletes the user document', () => { + expect(mockUser.deleteOne).toHaveBeenCalled(); + }); + it('calls req.logout', () => { + expect((request as any).logout).toHaveBeenCalled(); + }); + it('returns success', () => { + expect(response.json).toHaveBeenCalledWith({ + success: true, + message: 'Account successfully deleted.' + }); + }); + }); + + describe('and the correct password is supplied but S3 deletion fails', () => { + beforeEach(async () => { + (mockUser.comparePassword as jest.Mock).mockResolvedValue(true); + (deleteAllObjectsForUser as jest.Mock).mockRejectedValue( + new Error('S3 network error') + ); + request.user = mockUser; + request.body = { password: 'correctpassword' }; + (request as any).logout = jest.fn((cb: (err: null) => void) => + cb(null) + ); + (request as any).session = { + destroy: jest.fn((cb) => cb && cb()) + }; + await deleteAccount( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); + }); + it('still deletes the users projects', () => { + expect(Project.deleteMany).toHaveBeenCalledWith({ user: mockObjectId }); + }); + it('still deletes the users collections', () => { + expect(Collection.deleteMany).toHaveBeenCalledWith({ + owner: mockObjectId + }); + }); + it('still deletes the user document', () => { + expect(mockUser.deleteOne).toHaveBeenCalled(); + }); + it('still returns success', () => { + expect(response.json).toHaveBeenCalledWith({ + success: true, + message: 'Account successfully deleted.' + }); + }); + }); + }); + + describe('when the user has only social logins (no password)', () => { + let mockUser: UserDocument; + + beforeEach(async () => { + mockUser = createMockUser( + { + _id: mockObjectId, + password: undefined, + github: 'githubuser', + google: 'googleuser', + tokens: [{ kind: 'github' }, { kind: 'google' }], + deleteOne: jest.fn().mockResolvedValue(null) + }, + true + ) as UserDocument; + User.findById = jest.fn().mockResolvedValue(mockUser); + request.user = mockUser; + request.body = {}; + (request as any).logout = jest.fn((cb: (err: null) => void) => cb(null)); + (request as any).session = { + destroy: jest.fn((cb) => cb && cb()) + }; + await deleteAccount( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); + }); + + it('clears the github property', () => { + expect(mockUser.github).toBeUndefined(); + }); + it('clears the google property', () => { + expect(mockUser.google).toBeUndefined(); + }); + it('filters out all social tokens', () => { + expect(mockUser.tokens).toEqual([]); + }); + it('deletes all S3 assets for the user', () => { + expect(deleteAllObjectsForUser).toHaveBeenCalledWith( + mockObjectId.toString() + ); + }); + it('deletes the user document without requiring a password', () => { + expect(mockUser.deleteOne).toHaveBeenCalled(); + }); + it('calls req.logout', () => { + expect((request as any).logout).toHaveBeenCalled(); + }); + it('returns success', () => { + expect(response.json).toHaveBeenCalledWith({ + success: true, + message: 'Account successfully deleted.' + }); + }); + }); + + describe('when the user has both a password and social logins', () => { + let mockUser: UserDocument; + + beforeEach(async () => { + mockUser = createMockUser( + { + _id: mockObjectId, + password: 'hashed', + github: 'githubuser', + google: 'googleuser', + tokens: [{ kind: 'github' }, { kind: 'google' }], + comparePassword: jest.fn().mockResolvedValue(true), + deleteOne: jest.fn().mockResolvedValue(null) + }, + true + ) as UserDocument; + User.findById = jest.fn().mockResolvedValue(mockUser); + request.user = mockUser; + request.body = { password: 'correctpassword' }; + (request as any).logout = jest.fn((cb: (err: null) => void) => cb(null)); + (request as any).session = { + destroy: jest.fn((cb) => cb && cb()) + }; + await deleteAccount( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); + }); + + it('clears the github property', () => { + expect(mockUser.github).toBeUndefined(); + }); + it('clears the google property', () => { + expect(mockUser.google).toBeUndefined(); + }); + it('filters out all social tokens', () => { + expect(mockUser.tokens).toEqual([]); + }); + it('deletes all S3 assets for the user', () => { + expect(deleteAllObjectsForUser).toHaveBeenCalledWith( + mockObjectId.toString() + ); + }); + it('deletes the users projects', () => { + expect(Project.deleteMany).toHaveBeenCalledWith({ user: mockObjectId }); + }); + it('deletes the users collections', () => { + expect(Collection.deleteMany).toHaveBeenCalledWith({ + owner: mockObjectId + }); + }); + it('deletes the user document', () => { + expect(mockUser.deleteOne).toHaveBeenCalled(); + }); + it('calls req.logout', () => { + expect((request as any).logout).toHaveBeenCalled(); + }); + it('returns success', () => { + expect(response.json).toHaveBeenCalledWith({ + success: true, + message: 'Account successfully deleted.' + }); + }); + }); + + describe('when a database error occurs during deletion', () => { + let mockUserWithDbError: UserDocument; + + beforeEach(async () => { + mockUserWithDbError = createMockUser( + { + _id: mockObjectId, + password: 'hashed', + github: undefined, + google: undefined, + tokens: [], + comparePassword: jest.fn().mockResolvedValue(true), + deleteOne: jest.fn().mockRejectedValue(new Error('DB write error')) + }, + true + ) as UserDocument; + User.findById = jest.fn().mockResolvedValue(mockUserWithDbError); + request.user = mockUserWithDbError; + request.body = { password: 'correctpassword' }; + (request as any).logout = jest.fn((cb: (err: null) => void) => cb(null)); + (request as any).session = { + destroy: jest.fn((cb) => cb && cb()) + }; + await deleteAccount( + (request as unknown) as Request, + (response as unknown) as Response, + next + ); + }); + + it('returns 500', () => { + expect(response.status).toHaveBeenCalledWith(500); + }); + it('returns the correct error message', () => { + expect(response.json).toHaveBeenCalledWith({ + success: false, + message: 'Internal server error.' + }); + }); + }); +});