From 83505cb0d49637854dd8ec2db1a3c1910f365c6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Wed, 15 Feb 2023 09:00:41 +0100 Subject: [PATCH] ci(core): Add unit tests for "me" controller (no-changelog) (#5479) --- package.json | 1 + .../unit/controllers/me.controller.test.ts | 166 ++++++++++++++++++ pnpm-lock.yaml | 21 +++ 3 files changed, 188 insertions(+) create mode 100644 packages/cli/test/unit/controllers/me.controller.test.ts diff --git a/package.json b/package.json index 190ba8cc55..8c642daef9 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "jest": "^29.4.2", "jest-environment-jsdom": "^29.4.2", "jest-mock": "^29.4.2", + "jest-mock-extended": "^3.0.1", "nock": "^13.2.9", "node-fetch": "^2.6.7", "p-limit": "^3.1.0", diff --git a/packages/cli/test/unit/controllers/me.controller.test.ts b/packages/cli/test/unit/controllers/me.controller.test.ts new file mode 100644 index 0000000000..1606915398 --- /dev/null +++ b/packages/cli/test/unit/controllers/me.controller.test.ts @@ -0,0 +1,166 @@ +import { CookieOptions, Response } from 'express'; +import type { Repository } from 'typeorm'; +import jwt from 'jsonwebtoken'; +import { mock, anyObject, captor } from 'jest-mock-extended'; +import type { ILogger } from 'n8n-workflow'; +import type { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces'; +import type { User } from '@db/entities/User'; +import { MeController } from '@/controllers'; +import { AUTH_COOKIE_NAME } from '@/constants'; +import { BadRequestError } from '@/ResponseHelper'; +import type { AuthenticatedRequest, MeRequest } from '@/requests'; + +describe('MeController', () => { + const logger = mock(); + const externalHooks = mock(); + const internalHooks = mock(); + const userRepository = mock>(); + + let controller: MeController; + beforeAll(() => { + controller = new MeController({ + logger, + externalHooks, + internalHooks, + repositories: { User: userRepository }, + }); + }); + + describe('updateCurrentUser', () => { + it('should throw BadRequestError if email is missing in the payload', async () => { + const req = mock({}); + 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' } }); + 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 req = mock({ + user: mock({ id: '123', password: 'password', authIdentities: [] }), + body: { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }, + }); + const res = mock(); + userRepository.save.calledWith(anyObject()).mockResolvedValue(req.user); + jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token'); + + await controller.updateCurrentUser(req, res); + + 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', [ + req.user.email, + anyObject(), + ]); + }); + }); + + 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: '' }, + }); + 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: '' }, + }); + expect(controller.updatePassword(req, mock())).rejects.toThrowError( + new BadRequestError('Provided current password is incorrect.'), + ); + }); + + describe('should throw if newPassword is not valid', () => { + Object.entries({ + pass: 'Password must be 8 to 64 characters long. Password must contain at least 1 number. Password must contain at least 1 uppercase letter.', + password: + 'Password must contain at least 1 number. Password must contain at least 1 uppercase letter.', + password1: 'Password must contain at least 1 uppercase letter.', + }).forEach(([newPassword, errorMessage]) => { + it(newPassword, async () => { + const req = mock({ + user: mock({ password: passwordHash }), + body: { currentPassword: 'old_password', newPassword }, + }); + 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(userRepository.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(userRepository.update).toHaveBeenCalledWith(req.user.id, { apiKey: null }); + }); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d56810db38..923b34c5aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,7 @@ importers: jest: ^29.4.2 jest-environment-jsdom: ^29.4.2 jest-mock: ^29.4.2 + jest-mock-extended: ^3.0.1 n8n: '*' nock: ^13.2.9 node-fetch: ^2.6.7 @@ -61,6 +62,7 @@ importers: jest: 29.4.2 jest-environment-jsdom: 29.4.2 jest-mock: 29.4.2 + jest-mock-extended: 3.0.1_47mstovy2thvxzoe7ouhdvjexm nock: 13.2.9 node-fetch: 2.6.7 p-limit: 3.1.0 @@ -13117,6 +13119,17 @@ packages: stack-utils: 2.0.6 dev: true + /jest-mock-extended/3.0.1_47mstovy2thvxzoe7ouhdvjexm: + resolution: {integrity: sha512-RF4Ow8pXvbRuEcCTj56oYHmig5311BSFvbEGxPNYL51wGKGu93MvVQgx0UpFmjqyBXIcElkZo2Rke88kR1iSKQ==} + peerDependencies: + jest: ^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0 + typescript: ^3.0.0 || ^4.0.0 + dependencies: + jest: 29.4.2 + ts-essentials: 7.0.3_typescript@4.9.4 + typescript: 4.9.4 + dev: true + /jest-mock/29.4.2: resolution: {integrity: sha512-x1FSd4Gvx2yIahdaIKoBjwji6XpboDunSJ95RpntGrYulI1ByuYQCKN/P7hvk09JB74IonU3IPLdkutEWYt++g==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -19222,6 +19235,14 @@ packages: engines: {node: '>=6.10'} dev: true + /ts-essentials/7.0.3_typescript@4.9.4: + resolution: {integrity: sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ==} + peerDependencies: + typescript: '>=3.7.0' + dependencies: + typescript: 4.9.4 + dev: true + /ts-expect/1.3.0: resolution: {integrity: sha512-e4g0EJtAjk64xgnFPD6kTBUtpnMVzDrMb12N1YZV0VvSlhnVT3SGxiYTLdGy8Q5cYHOIC/FAHmZ10eGrAguicQ==} dev: false