const mockGetUserById = jest.fn(); const mockDeleteMessages = jest.fn(); const mockDeleteAllUserSessions = jest.fn(); const mockDeleteUserById = jest.fn(); const mockDeleteAllSharedLinks = jest.fn(); const mockDeletePresets = jest.fn(); const mockDeleteUserKey = jest.fn(); const mockDeleteConvos = jest.fn(); const mockDeleteFiles = jest.fn(); const mockGetFiles = jest.fn(); const mockUpdateUserPlugins = jest.fn(); const mockUpdateUser = jest.fn(); const mockFindToken = jest.fn(); const mockVerifyOTPOrBackupCode = jest.fn(); const mockDeleteUserPluginAuth = jest.fn(); const mockProcessDeleteRequest = jest.fn(); const mockDeleteToolCalls = jest.fn(); const mockDeleteUserAgents = jest.fn(); const mockDeleteUserPrompts = jest.fn(); jest.mock('@librechat/data-schemas', () => ({ logger: { error: jest.fn(), info: jest.fn() }, webSearchKeys: [], })); jest.mock('librechat-data-provider', () => ({ Tools: {}, CacheKeys: {}, Constants: { mcp_delimiter: '::', mcp_prefix: 'mcp_' }, FileSources: {}, })); jest.mock('@librechat/api', () => ({ MCPOAuthHandler: {}, MCPTokenStorage: {}, normalizeHttpError: jest.fn(), extractWebSearchEnvVars: jest.fn(), })); jest.mock('~/models', () => ({ deleteAllUserSessions: (...args) => mockDeleteAllUserSessions(...args), deleteAllSharedLinks: (...args) => mockDeleteAllSharedLinks(...args), updateUserPlugins: (...args) => mockUpdateUserPlugins(...args), deleteUserById: (...args) => mockDeleteUserById(...args), deleteMessages: (...args) => mockDeleteMessages(...args), deletePresets: (...args) => mockDeletePresets(...args), deleteUserKey: (...args) => mockDeleteUserKey(...args), getUserById: (...args) => mockGetUserById(...args), deleteConvos: (...args) => mockDeleteConvos(...args), deleteFiles: (...args) => mockDeleteFiles(...args), updateUser: (...args) => mockUpdateUser(...args), findToken: (...args) => mockFindToken(...args), getFiles: (...args) => mockGetFiles(...args), })); jest.mock('~/db/models', () => ({ ConversationTag: { deleteMany: jest.fn() }, AgentApiKey: { deleteMany: jest.fn() }, Transaction: { deleteMany: jest.fn() }, MemoryEntry: { deleteMany: jest.fn() }, Assistant: { deleteMany: jest.fn() }, AclEntry: { deleteMany: jest.fn() }, Balance: { deleteMany: jest.fn() }, Action: { deleteMany: jest.fn() }, Group: { updateMany: jest.fn() }, Token: { deleteMany: jest.fn() }, User: {}, })); jest.mock('~/server/services/PluginService', () => ({ updateUserPluginAuth: jest.fn(), deleteUserPluginAuth: (...args) => mockDeleteUserPluginAuth(...args), })); jest.mock('~/server/services/twoFactorService', () => ({ verifyOTPOrBackupCode: (...args) => mockVerifyOTPOrBackupCode(...args), })); jest.mock('~/server/services/AuthService', () => ({ verifyEmail: jest.fn(), resendVerificationEmail: jest.fn(), })); jest.mock('~/config', () => ({ getMCPManager: jest.fn(), getFlowStateManager: jest.fn(), getMCPServersRegistry: jest.fn(), })); jest.mock('~/server/services/Config/getCachedTools', () => ({ invalidateCachedTools: jest.fn(), })); jest.mock('~/server/services/Files/S3/crud', () => ({ needsRefresh: jest.fn(), getNewS3URL: jest.fn(), })); jest.mock('~/server/services/Files/process', () => ({ processDeleteRequest: (...args) => mockProcessDeleteRequest(...args), })); jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn(), })); jest.mock('~/models/ToolCall', () => ({ deleteToolCalls: (...args) => mockDeleteToolCalls(...args), })); jest.mock('~/models/Prompt', () => ({ deleteUserPrompts: (...args) => mockDeleteUserPrompts(...args), })); jest.mock('~/models/Agent', () => ({ deleteUserAgents: (...args) => mockDeleteUserAgents(...args), })); jest.mock('~/cache', () => ({ getLogStores: jest.fn(), })); const { deleteUserController } = require('~/server/controllers/UserController'); function createRes() { const res = {}; res.status = jest.fn().mockReturnValue(res); res.json = jest.fn().mockReturnValue(res); res.send = jest.fn().mockReturnValue(res); return res; } function stubDeletionMocks() { mockDeleteMessages.mockResolvedValue(); mockDeleteAllUserSessions.mockResolvedValue(); mockDeleteUserKey.mockResolvedValue(); mockDeletePresets.mockResolvedValue(); mockDeleteConvos.mockResolvedValue(); mockDeleteUserPluginAuth.mockResolvedValue(); mockDeleteUserById.mockResolvedValue(); mockDeleteAllSharedLinks.mockResolvedValue(); mockGetFiles.mockResolvedValue([]); mockProcessDeleteRequest.mockResolvedValue(); mockDeleteFiles.mockResolvedValue(); mockDeleteToolCalls.mockResolvedValue(); mockDeleteUserAgents.mockResolvedValue(); mockDeleteUserPrompts.mockResolvedValue(); } beforeEach(() => { jest.clearAllMocks(); stubDeletionMocks(); }); describe('deleteUserController - 2FA enforcement', () => { it('proceeds with deletion when 2FA is not enabled', async () => { const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: {} }; const res = createRes(); mockGetUserById.mockResolvedValue({ _id: 'user1', twoFactorEnabled: false }); await deleteUserController(req, res); expect(res.status).toHaveBeenCalledWith(200); expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' }); expect(mockDeleteMessages).toHaveBeenCalled(); expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled(); }); it('proceeds with deletion when user has no 2FA record', async () => { const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: {} }; const res = createRes(); mockGetUserById.mockResolvedValue(null); await deleteUserController(req, res); expect(res.status).toHaveBeenCalledWith(200); expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' }); }); it('returns error when 2FA is enabled and verification fails with 400', async () => { const req = { user: { id: 'user1', _id: 'user1' }, body: {} }; const res = createRes(); mockGetUserById.mockResolvedValue({ _id: 'user1', twoFactorEnabled: true, totpSecret: 'enc-secret', }); mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 }); await deleteUserController(req, res); expect(res.status).toHaveBeenCalledWith(400); expect(mockDeleteMessages).not.toHaveBeenCalled(); }); it('returns 401 when 2FA is enabled and invalid TOTP token provided', async () => { const existingUser = { _id: 'user1', twoFactorEnabled: true, totpSecret: 'enc-secret', }; const req = { user: { id: 'user1', _id: 'user1' }, body: { token: 'wrong' } }; const res = createRes(); mockGetUserById.mockResolvedValue(existingUser); mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 401, message: 'Invalid token or backup code', }); await deleteUserController(req, res); expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({ user: existingUser, token: 'wrong', backupCode: undefined, }); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' }); expect(mockDeleteMessages).not.toHaveBeenCalled(); }); it('returns 401 when 2FA is enabled and invalid backup code provided', async () => { const existingUser = { _id: 'user1', twoFactorEnabled: true, totpSecret: 'enc-secret', backupCodes: [], }; const req = { user: { id: 'user1', _id: 'user1' }, body: { backupCode: 'bad-code' } }; const res = createRes(); mockGetUserById.mockResolvedValue(existingUser); mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 401, message: 'Invalid token or backup code', }); await deleteUserController(req, res); expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({ user: existingUser, token: undefined, backupCode: 'bad-code', }); expect(res.status).toHaveBeenCalledWith(401); expect(mockDeleteMessages).not.toHaveBeenCalled(); }); it('deletes account when valid TOTP token provided with 2FA enabled', async () => { const existingUser = { _id: 'user1', twoFactorEnabled: true, totpSecret: 'enc-secret', }; const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: { token: '123456' }, }; const res = createRes(); mockGetUserById.mockResolvedValue(existingUser); mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true }); await deleteUserController(req, res); expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({ user: existingUser, token: '123456', backupCode: undefined, }); expect(res.status).toHaveBeenCalledWith(200); expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' }); expect(mockDeleteMessages).toHaveBeenCalled(); }); it('deletes account when valid backup code provided with 2FA enabled', async () => { const existingUser = { _id: 'user1', twoFactorEnabled: true, totpSecret: 'enc-secret', backupCodes: [{ codeHash: 'h1', used: false }], }; const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: { backupCode: 'valid-code' }, }; const res = createRes(); mockGetUserById.mockResolvedValue(existingUser); mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true }); await deleteUserController(req, res); expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({ user: existingUser, token: undefined, backupCode: 'valid-code', }); expect(res.status).toHaveBeenCalledWith(200); expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' }); expect(mockDeleteMessages).toHaveBeenCalled(); }); });