| import { UserUpdateRequestDto } from '@n8n/api-types'; |
| import type { User } from '@n8n/db'; |
| import type { PublicUser } from '@n8n/db'; |
| import { InvalidAuthTokenRepository } from '@n8n/db'; |
| import { UserRepository } from '@n8n/db'; |
| import { Container } from '@n8n/di'; |
| import type { Response } from 'express'; |
| import { mock, anyObject } from 'jest-mock-extended'; |
| import jwt from 'jsonwebtoken'; |
|
|
| import { AUTH_COOKIE_NAME } from '@/constants'; |
| import { MeController } from '@/controllers/me.controller'; |
| import { BadRequestError } from '@/errors/response-errors/bad-request.error'; |
| import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error'; |
| import { EventService } from '@/events/event.service'; |
| import { ExternalHooks } from '@/external-hooks'; |
| import { License } from '@/license'; |
| import { MfaService } from '@/mfa/mfa.service'; |
| import type { AuthenticatedRequest, MeRequest } from '@/requests'; |
| import { UserService } from '@/services/user.service'; |
| import { mockInstance } from '@test/mocking'; |
| import { badPasswords } from '@test/test-data'; |
|
|
| const browserId = 'test-browser-id'; |
|
|
| describe('MeController', () => { |
| const externalHooks = mockInstance(ExternalHooks); |
| const eventService = mockInstance(EventService); |
| const userService = mockInstance(UserService); |
| const userRepository = mockInstance(UserRepository); |
| const mockMfaService = mockInstance(MfaService); |
| mockInstance(InvalidAuthTokenRepository); |
| mockInstance(License).isWithinUsersLimit.mockReturnValue(true); |
| const controller = Container.get(MeController); |
|
|
| describe('updateCurrentUser', () => { |
| it('should update the user in the DB, and issue a new cookie', async () => { |
| const user = mock<User>({ |
| id: '123', |
| email: 'valid@email.com', |
| password: 'password', |
| authIdentities: [], |
| role: 'global:owner', |
| mfaEnabled: false, |
| }); |
| const payload = new UserUpdateRequestDto({ |
| email: 'valid@email.com', |
| firstName: 'John', |
| lastName: 'Potato', |
| }); |
| const req = mock<AuthenticatedRequest>({ user, browserId }); |
| const res = mock<Response>(); |
| userRepository.findOneByOrFail.mockResolvedValue(user); |
| userRepository.findOneOrFail.mockResolvedValue(user); |
| jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token'); |
| userService.toPublic.mockResolvedValue({} as unknown as PublicUser); |
|
|
| await controller.updateCurrentUser(req, res, payload); |
|
|
| expect(externalHooks.run).toHaveBeenCalledWith('user.profile.beforeUpdate', [ |
| user.id, |
| user.email, |
| payload, |
| ]); |
|
|
| expect(userService.update).toHaveBeenCalled(); |
| expect(eventService.emit).toHaveBeenCalledWith('user-updated', { |
| user, |
| fieldsChanged: ['firstName', 'lastName'], |
| }); |
| expect(res.cookie).toHaveBeenCalledWith( |
| AUTH_COOKIE_NAME, |
| 'signed-token', |
| expect.objectContaining({ |
| maxAge: expect.any(Number), |
| httpOnly: true, |
| sameSite: 'lax', |
| secure: false, |
| }), |
| ); |
|
|
| expect(externalHooks.run).toHaveBeenCalledWith('user.profile.update', [ |
| user.email, |
| anyObject(), |
| ]); |
| }); |
|
|
| it('should throw BadRequestError if beforeUpdate hook throws BadRequestError', async () => { |
| const user = mock<User>({ |
| id: '123', |
| password: 'password', |
| authIdentities: [], |
| role: 'global:owner', |
| mfaEnabled: false, |
| }); |
| const req = mock<AuthenticatedRequest>({ user }); |
|
|
| externalHooks.run.mockImplementationOnce(async (hookName) => { |
| if (hookName === 'user.profile.beforeUpdate') { |
| throw new BadRequestError('Invalid email address'); |
| } |
| }); |
|
|
| await expect( |
| controller.updateCurrentUser( |
| req, |
| mock(), |
| mock({ email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }), |
| ), |
| ).rejects.toThrowError(new BadRequestError('Invalid email address')); |
| }); |
|
|
| describe('when mfa is enabled', () => { |
| it('should throw BadRequestError if mfa code is missing', async () => { |
| const user = mock<User>({ |
| id: '123', |
| email: 'valid@email.com', |
| password: 'password', |
| authIdentities: [], |
| role: 'global:owner', |
| mfaEnabled: true, |
| }); |
| const req = mock<AuthenticatedRequest>({ user, browserId }); |
|
|
| await expect( |
| controller.updateCurrentUser( |
| req, |
| mock(), |
| new UserUpdateRequestDto({ |
| email: 'new@email.com', |
| firstName: 'John', |
| lastName: 'Potato', |
| }), |
| ), |
| ).rejects.toThrowError(new BadRequestError('Two-factor code is required to change email')); |
| }); |
|
|
| it('should throw InvalidMfaCodeError if mfa code is invalid', async () => { |
| const user = mock<User>({ |
| id: '123', |
| email: 'valid@email.com', |
| password: 'password', |
| authIdentities: [], |
| role: 'global:owner', |
| mfaEnabled: true, |
| }); |
| const req = mock<AuthenticatedRequest>({ user, browserId }); |
| mockMfaService.validateMfa.mockResolvedValue(false); |
|
|
| await expect( |
| controller.updateCurrentUser( |
| req, |
| mock(), |
| mock({ |
| email: 'new@email.com', |
| firstName: 'John', |
| lastName: 'Potato', |
| mfaCode: 'invalid', |
| }), |
| ), |
| ).rejects.toThrow(InvalidMfaCodeError); |
| }); |
|
|
| it("should update the user's email if mfa code is valid", async () => { |
| const user = mock<User>({ |
| id: '123', |
| email: 'valid@email.com', |
| password: 'password', |
| authIdentities: [], |
| role: 'global:owner', |
| mfaEnabled: true, |
| mfaSecret: 'secret', |
| }); |
| const req = mock<AuthenticatedRequest>({ user, browserId }); |
| const res = mock<Response>(); |
| userRepository.findOneByOrFail.mockResolvedValue(user); |
| userRepository.findOneOrFail.mockResolvedValue(user); |
| jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token'); |
| userService.toPublic.mockResolvedValue({} as unknown as PublicUser); |
| mockMfaService.validateMfa.mockResolvedValue(true); |
|
|
| const result = await controller.updateCurrentUser( |
| req, |
| res, |
| mock({ |
| email: 'new@email.com', |
| firstName: 'John', |
| lastName: 'Potato', |
| mfaCode: '123456', |
| }), |
| ); |
|
|
| expect(result).toEqual({}); |
| }); |
| }); |
| }); |
|
|
| describe('updatePassword', () => { |
| const passwordHash = '$2a$10$ffitcKrHT.Ls.m9FfWrMrOod76aaI0ogKbc3S96Q320impWpCbgj6'; |
|
|
| it('should throw if the user does not have a password set', async () => { |
| const req = mock<AuthenticatedRequest>({ |
| user: mock({ password: undefined }), |
| }); |
| await expect( |
| controller.updatePassword(req, mock(), mock({ currentPassword: '', newPassword: '' })), |
| ).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<AuthenticatedRequest>({ |
| user: mock({ password: passwordHash }), |
| }); |
| await expect( |
| controller.updatePassword( |
| req, |
| mock(), |
| mock({ currentPassword: 'not_old_password', newPassword: '' }), |
| ), |
| ).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<AuthenticatedRequest>({ |
| user: mock({ password: passwordHash }), |
| browserId, |
| }); |
| await expect( |
| controller.updatePassword( |
| req, |
| mock(), |
| mock({ currentPassword: 'old_password', newPassword }), |
| ), |
| ).rejects.toThrowError(new BadRequestError(errorMessage)); |
| }); |
| }); |
| }); |
|
|
| it('should update the password in the DB, and issue a new cookie', async () => { |
| const req = mock<AuthenticatedRequest>({ |
| user: mock({ password: passwordHash, mfaEnabled: false }), |
| browserId, |
| }); |
| const res = mock<Response>(); |
| userRepository.save.calledWith(req.user).mockResolvedValue(req.user); |
| jest.spyOn(jwt, 'sign').mockImplementation(() => 'new-signed-token'); |
|
|
| await controller.updatePassword( |
| req, |
| res, |
| mock({ currentPassword: 'old_password', newPassword: 'NewPassword123' }), |
| ); |
|
|
| expect(req.user.password).not.toBe(passwordHash); |
|
|
| expect(res.cookie).toHaveBeenCalledWith( |
| AUTH_COOKIE_NAME, |
| 'new-signed-token', |
| expect.objectContaining({ |
| maxAge: expect.any(Number), |
| httpOnly: true, |
| sameSite: 'lax', |
| secure: false, |
| }), |
| ); |
|
|
| expect(externalHooks.run).toHaveBeenCalledWith('user.password.update', [ |
| req.user.email, |
| req.user.password, |
| ]); |
|
|
| expect(eventService.emit).toHaveBeenCalledWith('user-updated', { |
| user: req.user, |
| fieldsChanged: ['password'], |
| }); |
| }); |
|
|
| describe('mfa enabled', () => { |
| it('should throw BadRequestError if mfa code is missing', async () => { |
| const req = mock<AuthenticatedRequest>({ |
| user: mock({ password: passwordHash, mfaEnabled: true }), |
| }); |
|
|
| await expect( |
| controller.updatePassword( |
| req, |
| mock(), |
| mock({ currentPassword: 'old_password', newPassword: 'NewPassword123' }), |
| ), |
| ).rejects.toThrowError( |
| new BadRequestError('Two-factor code is required to change password.'), |
| ); |
| }); |
|
|
| it('should throw InvalidMfaCodeError if invalid mfa code is given', async () => { |
| const req = mock<AuthenticatedRequest>({ |
| user: mock({ password: passwordHash, mfaEnabled: true }), |
| }); |
| mockMfaService.validateMfa.mockResolvedValue(false); |
|
|
| await expect( |
| controller.updatePassword( |
| req, |
| mock(), |
| mock({ |
| currentPassword: 'old_password', |
| newPassword: 'NewPassword123', |
| mfaCode: '123', |
| }), |
| ), |
| ).rejects.toThrow(InvalidMfaCodeError); |
| }); |
|
|
| it('should succeed when mfa code is correct', async () => { |
| const req = mock<AuthenticatedRequest>({ |
| user: mock({ password: passwordHash, mfaEnabled: true, mfaSecret: 'secret' }), |
| browserId, |
| }); |
| const res = mock<Response>(); |
| userRepository.save.calledWith(req.user).mockResolvedValue(req.user); |
| jest.spyOn(jwt, 'sign').mockImplementation(() => 'new-signed-token'); |
| mockMfaService.validateMfa.mockResolvedValue(true); |
|
|
| const result = await controller.updatePassword( |
| req, |
| res, |
| mock({ |
| currentPassword: 'old_password', |
| newPassword: 'NewPassword123', |
| mfaCode: 'valid', |
| }), |
| ); |
|
|
| expect(result).toEqual({ success: true }); |
| expect(req.user.password).not.toBe(passwordHash); |
| }); |
| }); |
| }); |
|
|
| describe('storeSurveyAnswers', () => { |
| it('should throw BadRequestError if answers are missing in the payload', async () => { |
| const req = mock<MeRequest.SurveyAnswers>({ |
| body: undefined, |
| }); |
| await expect(controller.storeSurveyAnswers(req)).rejects.toThrowError( |
| new BadRequestError('Personalization answers are mandatory'), |
| ); |
| }); |
|
|
| it('should not flag XSS attempt for `<` sign in company size', async () => { |
| const req = mock<MeRequest.SurveyAnswers>(); |
| req.body = { |
| version: 'v4', |
| personalization_survey_submitted_at: '2024-08-06T12:19:51.268Z', |
| personalization_survey_n8n_version: '1.0.0', |
| companySize: '<20', |
| otherCompanyIndustryExtended: ['test'], |
| automationGoalSm: ['test'], |
| usageModes: ['test'], |
| email: 'test@email.com', |
| role: 'test', |
| roleOther: 'test', |
| reportedSource: 'test', |
| reportedSourceOther: 'test', |
| }; |
|
|
| await expect(controller.storeSurveyAnswers(req)).resolves.toEqual({ success: true }); |
| }); |
|
|
| test.each([ |
| 'automationGoalDevops', |
| 'companyIndustryExtended', |
| 'otherCompanyIndustryExtended', |
| 'automationGoalSm', |
| 'usageModes', |
| ])('should throw BadRequestError on XSS attempt for an array field %s', async (fieldName) => { |
| const req = mock<MeRequest.SurveyAnswers>(); |
| req.body = { |
| version: 'v4', |
| personalization_survey_n8n_version: '1.0.0', |
| personalization_survey_submitted_at: new Date().toISOString(), |
| [fieldName]: ['<script>alert("XSS")</script>'], |
| }; |
|
|
| await expect(controller.storeSurveyAnswers(req)).rejects.toThrowError(BadRequestError); |
| }); |
|
|
| test.each([ |
| 'automationGoalDevopsOther', |
| 'companySize', |
| 'companyType', |
| 'automationGoalSmOther', |
| 'roleOther', |
| 'reportedSource', |
| 'reportedSourceOther', |
| ])('should throw BadRequestError on XSS attempt for a string field %s', async (fieldName) => { |
| const req = mock<MeRequest.SurveyAnswers>(); |
| req.body = { |
| version: 'v4', |
| personalization_survey_n8n_version: '1.0.0', |
| personalization_survey_submitted_at: new Date().toISOString(), |
| [fieldName]: '<script>alert("XSS")</script>', |
| }; |
|
|
| await expect(controller.storeSurveyAnswers(req)).rejects.toThrowError(BadRequestError); |
| }); |
| }); |
| }); |
|
|