import type { CookieOptions, Response } from 'express'; import { Container } from 'typedi'; import jwt from 'jsonwebtoken'; import { mock, anyObject, captor } from 'jest-mock-extended'; import type { PublicUser } from '@/Interfaces'; import type { User } from '@db/entities/User'; import { MeController } from '@/controllers/me.controller'; import { AUTH_COOKIE_NAME } from '@/constants'; import type { AuthenticatedRequest, MeRequest } from '@/requests'; import { UserService } from '@/services/user.service'; import { ExternalHooks } from '@/ExternalHooks'; import { InternalHooks } from '@/InternalHooks'; import { License } from '@/License'; import { badPasswords } from '../shared/testData'; import { mockInstance } from '../../shared/mocking'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { UserRepository } from '@/databases/repositories/user.repository'; describe('MeController', () => { const externalHooks = mockInstance(ExternalHooks); const internalHooks = mockInstance(InternalHooks); const userService = mockInstance(UserService); const userRepository = mockInstance(UserRepository); mockInstance(License).isWithinUsersLimit.mockReturnValue(true); const controller = Container.get(MeController); describe('updateCurrentUser', () => { it('should throw BadRequestError if email is missing in the payload', async () => { const req = mock({}); await expect(controller.updateCurrentUser(req, mock())).rejects.toThrowError( new BadRequestError('Email is mandatory'), ); }); it('should throw BadRequestError if email is invalid', async () => { const req = mock({ body: { email: 'invalid-email' } }); await expect(controller.updateCurrentUser(req, mock())).rejects.toThrowError( new BadRequestError('Invalid email address'), ); }); it('should update the user in the DB, and issue a new cookie', async () => { const user = mock({ id: '123', password: 'password', authIdentities: [], globalRoleId: '1', }); const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }; const req = mock({ user, body: reqBody }); const res = mock(); userRepository.findOneOrFail.mockResolvedValue(user); jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token'); userService.toPublic.mockResolvedValue({} as unknown as PublicUser); await controller.updateCurrentUser(req, res); expect(externalHooks.run).toHaveBeenCalledWith('user.profile.beforeUpdate', [ user.id, user.email, reqBody, ]); expect(userService.update).toHaveBeenCalled(); const cookieOptions = captor(); expect(res.cookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME, 'signed-token', cookieOptions); expect(cookieOptions.value.httpOnly).toBe(true); expect(cookieOptions.value.sameSite).toBe('lax'); expect(externalHooks.run).toHaveBeenCalledWith('user.profile.update', [ user.email, anyObject(), ]); }); it('should not allow updating any other fields on a user besides email and name', async () => { const user = mock({ id: '123', password: 'password', authIdentities: [], globalRoleId: '1', }); const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }; const req = mock({ user, body: reqBody }); const res = mock(); userRepository.findOneOrFail.mockResolvedValue(user); jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token'); // Add invalid data to the request payload Object.assign(reqBody, { id: '0', globalRoleId: '42' }); await controller.updateCurrentUser(req, res); expect(userService.update).toHaveBeenCalled(); const updatedUser = userService.update.mock.calls[0][1]; expect(updatedUser.email).toBe(reqBody.email); expect(updatedUser.firstName).toBe(reqBody.firstName); expect(updatedUser.lastName).toBe(reqBody.lastName); expect(updatedUser.id).not.toBe('0'); expect(updatedUser.globalRoleId).not.toBe('42'); }); it('should throw BadRequestError if beforeUpdate hook throws BadRequestError', async () => { const user = mock({ id: '123', password: 'password', authIdentities: [], globalRoleId: '1', }); const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }; const req = mock({ user, body: reqBody }); userService.findOneOrFail.mockResolvedValue(user); externalHooks.run.mockImplementationOnce(async (hookName) => { if (hookName === 'user.profile.beforeUpdate') { throw new BadRequestError('Invalid email address'); } }); await expect(controller.updateCurrentUser(req, mock())).rejects.toThrowError( new BadRequestError('Invalid email address'), ); }); }); describe('updatePassword', () => { const passwordHash = '$2a$10$ffitcKrHT.Ls.m9FfWrMrOod76aaI0ogKbc3S96Q320impWpCbgj6'; // Hashed 'old_password' it('should throw if the user does not have a password set', async () => { const req = mock({ user: mock({ password: undefined }), body: { currentPassword: '', newPassword: '' }, }); await expect(controller.updatePassword(req, mock())).rejects.toThrowError( new BadRequestError('Requesting user not set up.'), ); }); it("should throw if currentPassword does not match the user's password", async () => { const req = mock({ user: mock({ password: passwordHash }), body: { currentPassword: 'not_old_password', newPassword: '' }, }); await expect(controller.updatePassword(req, mock())).rejects.toThrowError( new BadRequestError('Provided current password is incorrect.'), ); }); describe('should throw if newPassword is not valid', () => { Object.entries(badPasswords).forEach(([newPassword, errorMessage]) => { it(newPassword, async () => { const req = mock({ user: mock({ password: passwordHash }), body: { currentPassword: 'old_password', newPassword }, }); await expect(controller.updatePassword(req, mock())).rejects.toThrowError( new BadRequestError(errorMessage), ); }); }); }); it('should update the password in the DB, and issue a new cookie', async () => { const req = mock({ user: mock({ password: passwordHash }), body: { currentPassword: 'old_password', newPassword: 'NewPassword123' }, }); const res = mock(); userRepository.save.calledWith(req.user).mockResolvedValue(req.user); jest.spyOn(jwt, 'sign').mockImplementation(() => 'new-signed-token'); await controller.updatePassword(req, res); expect(req.user.password).not.toBe(passwordHash); const cookieOptions = captor(); expect(res.cookie).toHaveBeenCalledWith(AUTH_COOKIE_NAME, 'new-signed-token', cookieOptions); expect(cookieOptions.value.httpOnly).toBe(true); expect(cookieOptions.value.sameSite).toBe('lax'); expect(externalHooks.run).toHaveBeenCalledWith('user.password.update', [ req.user.email, req.user.password, ]); expect(internalHooks.onUserUpdate).toHaveBeenCalledWith({ user: req.user, fields_changed: ['password'], }); }); }); describe('API Key methods', () => { let req: AuthenticatedRequest; beforeAll(() => { req = mock({ user: mock>({ id: '123', apiKey: 'test-key' }) }); }); describe('createAPIKey', () => { it('should create and save an API key', async () => { const { apiKey } = await controller.createAPIKey(req); expect(userService.update).toHaveBeenCalledWith(req.user.id, { apiKey }); }); }); describe('getAPIKey', () => { it('should return the users api key', async () => { const { apiKey } = await controller.getAPIKey(req); expect(apiKey).toEqual(req.user.apiKey); }); }); describe('deleteAPIKey', () => { it('should delete the API key', async () => { await controller.deleteAPIKey(req); expect(userService.update).toHaveBeenCalledWith(req.user.id, { apiKey: null }); }); }); }); });