test(core): Move unit tests closer to testable components (no-changelog) (#10287)
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import type { Request } from 'express';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { CurlController } from '@/controllers/curl.controller';
|
||||
import type { CurlService } from '@/services/curl.service';
|
||||
|
||||
describe('CurlController', () => {
|
||||
const service = mock<CurlService>();
|
||||
const controller = new CurlController(service);
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('toJson', () => {
|
||||
it('should throw BadRequestError when invalid cURL command is provided', () => {
|
||||
const req = mock<Request>();
|
||||
service.toHttpNodeParameters.mockImplementation(() => {
|
||||
throw new Error();
|
||||
});
|
||||
|
||||
expect(() => controller.toJson(req)).toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
it('should return flattened parameters when valid cURL command is provided', () => {
|
||||
const curlCommand = 'curl -v -X GET https://test.n8n.berlin/users';
|
||||
const req = mock<Request>();
|
||||
req.body = { curlCommand };
|
||||
service.toHttpNodeParameters.mockReturnValue({
|
||||
url: 'https://test.n8n.berlin/users',
|
||||
authentication: 'none',
|
||||
method: 'GET',
|
||||
sendHeaders: false,
|
||||
sendQuery: false,
|
||||
options: {
|
||||
redirect: { redirect: {} },
|
||||
response: { response: {} },
|
||||
},
|
||||
sendBody: false,
|
||||
});
|
||||
|
||||
const result = controller.toJson(req);
|
||||
expect(result).toEqual({
|
||||
'parameters.method': 'GET',
|
||||
'parameters.url': 'https://test.n8n.berlin/users',
|
||||
'parameters.authentication': 'none',
|
||||
'parameters.sendBody': false,
|
||||
'parameters.sendHeaders': false,
|
||||
'parameters.sendQuery': false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { DynamicNodeParametersController } from '@/controllers/dynamicNodeParameters.controller';
|
||||
import type { DynamicNodeParametersRequest } from '@/requests';
|
||||
import type { DynamicNodeParametersService } from '@/services/dynamicNodeParameters.service';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import * as AdditionalData from '@/WorkflowExecuteAdditionalData';
|
||||
import type { ILoadOptions, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
|
||||
|
||||
describe('DynamicNodeParametersController', () => {
|
||||
const service = mock<DynamicNodeParametersService>();
|
||||
const controller = new DynamicNodeParametersController(service);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getOptions', () => {
|
||||
it('should take `loadOptions` as object', async () => {
|
||||
jest
|
||||
.spyOn(AdditionalData, 'getBase')
|
||||
.mockResolvedValue(mock<IWorkflowExecuteAdditionalData>());
|
||||
|
||||
const req = mock<DynamicNodeParametersRequest.Options>();
|
||||
const loadOptions: ILoadOptions = {};
|
||||
req.body.loadOptions = loadOptions;
|
||||
|
||||
await controller.getOptions(req);
|
||||
|
||||
const zerothArg = service.getOptionsViaLoadOptions.mock.calls[0][0];
|
||||
|
||||
expect(zerothArg).toEqual(loadOptions);
|
||||
});
|
||||
});
|
||||
});
|
||||
250
packages/cli/src/controllers/__tests__/me.controller.test.ts
Normal file
250
packages/cli/src/controllers/__tests__/me.controller.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import type { Response } from 'express';
|
||||
import { Container } from 'typedi';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mock, anyObject } from 'jest-mock-extended';
|
||||
import type { PublicUser } from '@/Interfaces';
|
||||
import type { User } from '@db/entities/User';
|
||||
import { API_KEY_PREFIX, 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 { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { UserRepository } from '@/databases/repositories/user.repository';
|
||||
import { badPasswords } from '@test/testData';
|
||||
import { mockInstance } from '@test/mocking';
|
||||
|
||||
const browserId = 'test-browser-id';
|
||||
|
||||
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<MeRequest.UserUpdate>({});
|
||||
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<MeRequest.UserUpdate>({ 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<User>({
|
||||
id: '123',
|
||||
password: 'password',
|
||||
authIdentities: [],
|
||||
role: 'global:owner',
|
||||
});
|
||||
const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' };
|
||||
const req = mock<MeRequest.UserUpdate>({ user, body: reqBody, browserId });
|
||||
const res = mock<Response>();
|
||||
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();
|
||||
|
||||
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 not allow updating any other fields on a user besides email and name', async () => {
|
||||
const user = mock<User>({
|
||||
id: '123',
|
||||
password: 'password',
|
||||
authIdentities: [],
|
||||
role: 'global:member',
|
||||
});
|
||||
const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' };
|
||||
const req = mock<MeRequest.UserUpdate>({ user, browserId });
|
||||
req.body = reqBody;
|
||||
const res = mock<Response>();
|
||||
userRepository.findOneOrFail.mockResolvedValue(user);
|
||||
jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token');
|
||||
|
||||
// Add invalid data to the request payload
|
||||
Object.assign(reqBody, { id: '0', role: 'global:owner' });
|
||||
|
||||
await controller.updateCurrentUser(req, res);
|
||||
|
||||
expect(userService.update).toHaveBeenCalled();
|
||||
|
||||
const updatePayload = userService.update.mock.calls[0][1];
|
||||
expect(updatePayload.email).toBe(reqBody.email);
|
||||
expect(updatePayload.firstName).toBe(reqBody.firstName);
|
||||
expect(updatePayload.lastName).toBe(reqBody.lastName);
|
||||
expect(updatePayload.id).toBeUndefined();
|
||||
expect(updatePayload.role).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw BadRequestError if beforeUpdate hook throws BadRequestError', async () => {
|
||||
const user = mock<User>({
|
||||
id: '123',
|
||||
password: 'password',
|
||||
authIdentities: [],
|
||||
role: 'global:owner',
|
||||
});
|
||||
const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' };
|
||||
const req = mock<MeRequest.UserUpdate>({ 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<MeRequest.Password>({
|
||||
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<MeRequest.Password>({
|
||||
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<MeRequest.Password>({
|
||||
user: mock({ password: passwordHash }),
|
||||
body: { currentPassword: 'old_password', newPassword },
|
||||
browserId,
|
||||
});
|
||||
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<MeRequest.Password>({
|
||||
user: mock({ password: passwordHash }),
|
||||
body: { currentPassword: 'old_password', newPassword: 'NewPassword123' },
|
||||
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);
|
||||
|
||||
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(internalHooks.onUserUpdate).toHaveBeenCalledWith({
|
||||
user: req.user,
|
||||
fields_changed: ['password'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Key methods', () => {
|
||||
let req: AuthenticatedRequest;
|
||||
beforeAll(() => {
|
||||
req = mock({ user: mock<Partial<User>>({ id: '123', apiKey: `${API_KEY_PREFIX}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 redacted', async () => {
|
||||
const { apiKey } = await controller.getAPIKey(req);
|
||||
expect(apiKey).not.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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
111
packages/cli/src/controllers/__tests__/owner.controller.test.ts
Normal file
111
packages/cli/src/controllers/__tests__/owner.controller.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import Container from 'typedi';
|
||||
import type { Response } from 'express';
|
||||
import { anyObject, mock } from 'jest-mock-extended';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import type { AuthService } from '@/auth/auth.service';
|
||||
import config from '@/config';
|
||||
import { OwnerController } from '@/controllers/owner.controller';
|
||||
import type { User } from '@db/entities/User';
|
||||
import type { SettingsRepository } from '@db/repositories/settings.repository';
|
||||
import type { UserRepository } from '@db/repositories/user.repository';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import type { InternalHooks } from '@/InternalHooks';
|
||||
import { License } from '@/License';
|
||||
import type { OwnerRequest } from '@/requests';
|
||||
import type { UserService } from '@/services/user.service';
|
||||
import { PasswordUtility } from '@/services/password.utility';
|
||||
|
||||
import { mockInstance } from '@test/mocking';
|
||||
import { badPasswords } from '@test/testData';
|
||||
|
||||
describe('OwnerController', () => {
|
||||
const configGetSpy = jest.spyOn(config, 'getEnv');
|
||||
const internalHooks = mock<InternalHooks>();
|
||||
const authService = mock<AuthService>();
|
||||
const userService = mock<UserService>();
|
||||
const userRepository = mock<UserRepository>();
|
||||
const settingsRepository = mock<SettingsRepository>();
|
||||
mockInstance(License).isWithinUsersLimit.mockReturnValue(true);
|
||||
const controller = new OwnerController(
|
||||
mock(),
|
||||
internalHooks,
|
||||
settingsRepository,
|
||||
authService,
|
||||
userService,
|
||||
Container.get(PasswordUtility),
|
||||
mock(),
|
||||
userRepository,
|
||||
);
|
||||
|
||||
describe('setupOwner', () => {
|
||||
it('should throw a BadRequestError if the instance owner is already setup', async () => {
|
||||
configGetSpy.mockReturnValue(true);
|
||||
await expect(controller.setupOwner(mock(), mock())).rejects.toThrowError(
|
||||
new BadRequestError('Instance owner already setup'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a BadRequestError if the email is invalid', async () => {
|
||||
configGetSpy.mockReturnValue(false);
|
||||
const req = mock<OwnerRequest.Post>({ body: { email: 'invalid email' } });
|
||||
await expect(controller.setupOwner(req, mock())).rejects.toThrowError(
|
||||
new BadRequestError('Invalid email address'),
|
||||
);
|
||||
});
|
||||
|
||||
describe('should throw if the password is invalid', () => {
|
||||
Object.entries(badPasswords).forEach(([password, errorMessage]) => {
|
||||
it(password, async () => {
|
||||
configGetSpy.mockReturnValue(false);
|
||||
const req = mock<OwnerRequest.Post>({ body: { email: 'valid@email.com', password } });
|
||||
await expect(controller.setupOwner(req, mock())).rejects.toThrowError(
|
||||
new BadRequestError(errorMessage),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a BadRequestError if firstName & lastName are missing ', async () => {
|
||||
configGetSpy.mockReturnValue(false);
|
||||
const req = mock<OwnerRequest.Post>({
|
||||
body: { email: 'valid@email.com', password: 'NewPassword123', firstName: '', lastName: '' },
|
||||
});
|
||||
await expect(controller.setupOwner(req, mock())).rejects.toThrowError(
|
||||
new BadRequestError('First and last names are mandatory'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should setup the instance owner successfully', async () => {
|
||||
const user = mock<User>({
|
||||
id: 'userId',
|
||||
role: 'global:owner',
|
||||
authIdentities: [],
|
||||
});
|
||||
const browserId = 'test-browser-id';
|
||||
const req = mock<OwnerRequest.Post>({
|
||||
body: {
|
||||
email: 'valid@email.com',
|
||||
password: 'NewPassword123',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
},
|
||||
user,
|
||||
browserId,
|
||||
});
|
||||
const res = mock<Response>();
|
||||
configGetSpy.mockReturnValue(false);
|
||||
userRepository.findOneOrFail.calledWith(anyObject()).mockResolvedValue(user);
|
||||
userRepository.save.calledWith(anyObject()).mockResolvedValue(user);
|
||||
jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token');
|
||||
|
||||
await controller.setupOwner(req, res);
|
||||
|
||||
expect(userRepository.findOneOrFail).toHaveBeenCalledWith({
|
||||
where: { role: 'global:owner' },
|
||||
});
|
||||
expect(userRepository.save).toHaveBeenCalledWith(user, { transaction: false });
|
||||
expect(authService.issueCookie).toHaveBeenCalledWith(res, user, browserId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import config from '@/config';
|
||||
import type { TranslationRequest } from '@/controllers/translation.controller';
|
||||
import {
|
||||
TranslationController,
|
||||
CREDENTIAL_TRANSLATIONS_DIR,
|
||||
} from '@/controllers/translation.controller';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import type { CredentialTypes } from '@/CredentialTypes';
|
||||
|
||||
describe('TranslationController', () => {
|
||||
const configGetSpy = jest.spyOn(config, 'getEnv');
|
||||
const credentialTypes = mock<CredentialTypes>();
|
||||
const controller = new TranslationController(credentialTypes);
|
||||
|
||||
describe('getCredentialTranslation', () => {
|
||||
it('should throw 400 on invalid credential types', async () => {
|
||||
const credentialType = 'not-a-valid-credential-type';
|
||||
const req = mock<TranslationRequest.Credential>({ query: { credentialType } });
|
||||
credentialTypes.recognizes.calledWith(credentialType).mockReturnValue(false);
|
||||
|
||||
await expect(controller.getCredentialTranslation(req)).rejects.toThrowError(
|
||||
new BadRequestError(`Invalid Credential type: "${credentialType}"`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return translation json on valid credential types', async () => {
|
||||
const credentialType = 'credential-type';
|
||||
const req = mock<TranslationRequest.Credential>({ query: { credentialType } });
|
||||
configGetSpy.mockReturnValue('de');
|
||||
credentialTypes.recognizes.calledWith(credentialType).mockReturnValue(true);
|
||||
const response = { translation: 'string' };
|
||||
jest.mock(`${CREDENTIAL_TRANSLATIONS_DIR}/de/credential-type.json`, () => response, {
|
||||
virtual: true,
|
||||
});
|
||||
|
||||
expect(await controller.getCredentialTranslation(req)).toEqual(response);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { UserSettingsController } from '@/controllers/userSettings.controller';
|
||||
import type { NpsSurveyRequest } from '@/requests';
|
||||
import type { UserService } from '@/services/user.service';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { NpsSurveyState } from 'n8n-workflow';
|
||||
|
||||
const NOW = 1717607016208;
|
||||
jest.useFakeTimers({
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
describe('UserSettingsController', () => {
|
||||
const userService = mock<UserService>();
|
||||
const controller = new UserSettingsController(userService);
|
||||
|
||||
describe('NPS Survey', () => {
|
||||
test.each([
|
||||
[
|
||||
'updates user settings, setting response state to done',
|
||||
{
|
||||
responded: true,
|
||||
lastShownAt: 1717607016208,
|
||||
},
|
||||
[],
|
||||
],
|
||||
[
|
||||
'updates user settings, setting response state to done, ignoring other keys like waitForResponse',
|
||||
{
|
||||
responded: true,
|
||||
lastShownAt: 1717607016208,
|
||||
waitingForResponse: true,
|
||||
},
|
||||
['waitingForResponse'],
|
||||
],
|
||||
[
|
||||
'updates user settings, setting response state to done, ignoring other keys like ignoredCount',
|
||||
{
|
||||
responded: true,
|
||||
lastShownAt: 1717607016208,
|
||||
ignoredCount: 1,
|
||||
},
|
||||
['ignoredCount'],
|
||||
],
|
||||
[
|
||||
'updates user settings, setting response state to done, ignoring other unknown keys',
|
||||
{
|
||||
responded: true,
|
||||
lastShownAt: 1717607016208,
|
||||
x: 1,
|
||||
},
|
||||
['x'],
|
||||
],
|
||||
[
|
||||
'updates user settings, updating ignore count',
|
||||
{
|
||||
waitingForResponse: true,
|
||||
lastShownAt: 1717607016208,
|
||||
ignoredCount: 1,
|
||||
},
|
||||
[],
|
||||
],
|
||||
[
|
||||
'updates user settings, resetting to waiting state',
|
||||
{
|
||||
waitingForResponse: true,
|
||||
ignoredCount: 0,
|
||||
lastShownAt: 1717607016208,
|
||||
},
|
||||
[],
|
||||
],
|
||||
[
|
||||
'updates user settings, updating ignore count, ignoring unknown keys',
|
||||
{
|
||||
waitingForResponse: true,
|
||||
lastShownAt: 1717607016208,
|
||||
ignoredCount: 1,
|
||||
x: 1,
|
||||
},
|
||||
['x'],
|
||||
],
|
||||
])('%s', async (_, toUpdate, toIgnore: string[] | undefined) => {
|
||||
const req = mock<NpsSurveyRequest.NpsSurveyUpdate>();
|
||||
req.user.id = '1';
|
||||
req.body = toUpdate;
|
||||
await controller.updateNpsSurvey(req);
|
||||
|
||||
const npsSurvey = Object.keys(toUpdate).reduce(
|
||||
(accu, key) => {
|
||||
if ((toIgnore ?? []).includes(key)) {
|
||||
return accu;
|
||||
}
|
||||
accu[key] = (toUpdate as Record<string, unknown>)[key];
|
||||
return accu;
|
||||
},
|
||||
{} as Record<string, unknown>,
|
||||
);
|
||||
expect(userService.updateSettings).toHaveBeenCalledWith('1', { npsSurvey });
|
||||
});
|
||||
|
||||
it('updates user settings, setting response state to done', async () => {
|
||||
const req = mock<NpsSurveyRequest.NpsSurveyUpdate>();
|
||||
req.user.id = '1';
|
||||
|
||||
const npsSurvey: NpsSurveyState = {
|
||||
responded: true,
|
||||
lastShownAt: 1717607016208,
|
||||
};
|
||||
req.body = npsSurvey;
|
||||
|
||||
await controller.updateNpsSurvey(req);
|
||||
|
||||
expect(userService.updateSettings).toHaveBeenCalledWith('1', { npsSurvey });
|
||||
});
|
||||
|
||||
it('updates user settings, updating ignore count', async () => {
|
||||
const req = mock<NpsSurveyRequest.NpsSurveyUpdate>();
|
||||
req.user.id = '1';
|
||||
|
||||
const npsSurvey: NpsSurveyState = {
|
||||
waitingForResponse: true,
|
||||
lastShownAt: 1717607016208,
|
||||
ignoredCount: 1,
|
||||
};
|
||||
req.body = npsSurvey;
|
||||
|
||||
await controller.updateNpsSurvey(req);
|
||||
|
||||
expect(userService.updateSettings).toHaveBeenCalledWith('1', { npsSurvey });
|
||||
});
|
||||
|
||||
test.each([
|
||||
['is missing', {}],
|
||||
['is undefined', undefined],
|
||||
['is responded but missing lastShownAt', { responded: true }],
|
||||
['is waitingForResponse but missing lastShownAt', { waitingForResponse: true }],
|
||||
[
|
||||
'is waitingForResponse but missing ignoredCount',
|
||||
{ lastShownAt: 123, waitingForResponse: true },
|
||||
],
|
||||
])('throws error when request payload is %s', async (_, payload) => {
|
||||
const req = mock<NpsSurveyRequest.NpsSurveyUpdate>();
|
||||
req.user.id = '1';
|
||||
req.body = payload;
|
||||
|
||||
await expect(controller.updateNpsSurvey(req)).rejects.toThrowError();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import nock from 'nock';
|
||||
import Container from 'typedi';
|
||||
import type { Response } from 'express';
|
||||
import Csrf from 'csrf';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { OAuth1CredentialController } from '@/controllers/oauth/oAuth1Credential.controller';
|
||||
import { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
||||
import type { User } from '@db/entities/User';
|
||||
import type { OAuthRequest } from '@/requests';
|
||||
import { CredentialsRepository } from '@db/repositories/credentials.repository';
|
||||
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
|
||||
import { ExternalHooks } from '@/ExternalHooks';
|
||||
import { Logger } from '@/Logger';
|
||||
import { VariablesService } from '@/environments/variables/variables.service.ee';
|
||||
import { SecretsHelper } from '@/SecretsHelpers';
|
||||
import { CredentialsHelper } from '@/CredentialsHelper';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
import { mockInstance } from '@test/mocking';
|
||||
|
||||
describe('OAuth1CredentialController', () => {
|
||||
mockInstance(Logger);
|
||||
mockInstance(ExternalHooks);
|
||||
mockInstance(SecretsHelper);
|
||||
mockInstance(VariablesService, {
|
||||
getAllCached: async () => [],
|
||||
});
|
||||
const cipher = mockInstance(Cipher);
|
||||
const credentialsHelper = mockInstance(CredentialsHelper);
|
||||
const credentialsRepository = mockInstance(CredentialsRepository);
|
||||
const sharedCredentialsRepository = mockInstance(SharedCredentialsRepository);
|
||||
|
||||
const csrfSecret = 'csrf-secret';
|
||||
const user = mock<User>({
|
||||
id: '123',
|
||||
password: 'password',
|
||||
authIdentities: [],
|
||||
role: 'global:owner',
|
||||
});
|
||||
const credential = mock<CredentialsEntity>({
|
||||
id: '1',
|
||||
name: 'Test Credential',
|
||||
type: 'oAuth1Api',
|
||||
});
|
||||
|
||||
const controller = Container.get(OAuth1CredentialController);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('getAuthUri', () => {
|
||||
it('should throw a BadRequestError when credentialId is missing in the query', async () => {
|
||||
const req = mock<OAuthRequest.OAuth1Credential.Auth>({ query: { id: '' } });
|
||||
await expect(controller.getAuthUri(req)).rejects.toThrowError(
|
||||
new BadRequestError('Required credential ID is missing'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a NotFoundError when no matching credential is found for the user', async () => {
|
||||
sharedCredentialsRepository.findCredentialForUser.mockResolvedValueOnce(null);
|
||||
|
||||
const req = mock<OAuthRequest.OAuth1Credential.Auth>({ user, query: { id: '1' } });
|
||||
await expect(controller.getAuthUri(req)).rejects.toThrowError(
|
||||
new NotFoundError('Credential not found'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a valid auth URI', async () => {
|
||||
jest.spyOn(Csrf.prototype, 'secretSync').mockReturnValueOnce(csrfSecret);
|
||||
jest.spyOn(Csrf.prototype, 'create').mockReturnValueOnce('token');
|
||||
sharedCredentialsRepository.findCredentialForUser.mockResolvedValueOnce(credential);
|
||||
credentialsHelper.getDecrypted.mockResolvedValueOnce({});
|
||||
credentialsHelper.applyDefaultsAndOverwrites.mockReturnValueOnce({
|
||||
requestTokenUrl: 'https://example.domain/oauth/request_token',
|
||||
authUrl: 'https://example.domain/oauth/authorize',
|
||||
signatureMethod: 'HMAC-SHA1',
|
||||
});
|
||||
nock('https://example.domain')
|
||||
.post('/oauth/request_token', {
|
||||
oauth_callback:
|
||||
'http://localhost:5678/rest/oauth1-credential/callback?state=eyJ0b2tlbiI6InRva2VuIiwiY2lkIjoiMSJ9',
|
||||
})
|
||||
.reply(200, { oauth_token: 'random-token' });
|
||||
cipher.encrypt.mockReturnValue('encrypted');
|
||||
|
||||
const req = mock<OAuthRequest.OAuth1Credential.Auth>({ user, query: { id: '1' } });
|
||||
const authUri = await controller.getAuthUri(req);
|
||||
expect(authUri).toEqual('https://example.domain/oauth/authorize?oauth_token=random-token');
|
||||
expect(credentialsRepository.update).toHaveBeenCalledWith(
|
||||
'1',
|
||||
expect.objectContaining({
|
||||
data: 'encrypted',
|
||||
id: '1',
|
||||
name: 'Test Credential',
|
||||
type: 'oAuth1Api',
|
||||
}),
|
||||
);
|
||||
expect(cipher.encrypt).toHaveBeenCalledWith({ csrfSecret });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCallback', () => {
|
||||
const validState = Buffer.from(
|
||||
JSON.stringify({
|
||||
token: 'token',
|
||||
cid: '1',
|
||||
}),
|
||||
).toString('base64');
|
||||
|
||||
it('should render the error page when required query params are missing', async () => {
|
||||
const req = mock<OAuthRequest.OAuth1Credential.Callback>();
|
||||
const res = mock<Response>();
|
||||
req.query = { state: 'test' } as OAuthRequest.OAuth1Credential.Callback['query'];
|
||||
await controller.handleCallback(req, res);
|
||||
|
||||
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
|
||||
error: {
|
||||
message: 'Insufficient parameters for OAuth1 callback.',
|
||||
reason: 'Received following query parameters: {"state":"test"}',
|
||||
},
|
||||
});
|
||||
expect(credentialsRepository.findOneBy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render the error page when `state` query param is invalid', async () => {
|
||||
const req = mock<OAuthRequest.OAuth1Credential.Callback>();
|
||||
const res = mock<Response>();
|
||||
req.query = {
|
||||
oauth_verifier: 'verifier',
|
||||
oauth_token: 'token',
|
||||
state: 'test',
|
||||
} as OAuthRequest.OAuth1Credential.Callback['query'];
|
||||
await controller.handleCallback(req, res);
|
||||
|
||||
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
|
||||
error: {
|
||||
message: 'Invalid state format',
|
||||
},
|
||||
});
|
||||
expect(credentialsRepository.findOneBy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render the error page when credential is not found in DB', async () => {
|
||||
credentialsRepository.findOneBy.mockResolvedValueOnce(null);
|
||||
|
||||
const req = mock<OAuthRequest.OAuth1Credential.Callback>();
|
||||
const res = mock<Response>();
|
||||
req.query = {
|
||||
oauth_verifier: 'verifier',
|
||||
oauth_token: 'token',
|
||||
state: validState,
|
||||
} as OAuthRequest.OAuth1Credential.Callback['query'];
|
||||
await controller.handleCallback(req, res);
|
||||
|
||||
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
|
||||
error: {
|
||||
message: 'OAuth1 callback failed because of insufficient permissions',
|
||||
},
|
||||
});
|
||||
expect(credentialsRepository.findOneBy).toHaveBeenCalledTimes(1);
|
||||
expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({ id: '1' });
|
||||
});
|
||||
|
||||
it('should render the error page when state differs from the stored state in the credential', async () => {
|
||||
credentialsRepository.findOneBy.mockResolvedValue(new CredentialsEntity());
|
||||
credentialsHelper.getDecrypted.mockResolvedValue({ csrfSecret: 'invalid' });
|
||||
|
||||
const req = mock<OAuthRequest.OAuth1Credential.Callback>();
|
||||
const res = mock<Response>();
|
||||
req.query = {
|
||||
oauth_verifier: 'verifier',
|
||||
oauth_token: 'token',
|
||||
state: validState,
|
||||
} as OAuthRequest.OAuth1Credential.Callback['query'];
|
||||
|
||||
await controller.handleCallback(req, res);
|
||||
|
||||
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
|
||||
error: {
|
||||
message: 'The OAuth1 callback state is invalid!',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import nock from 'nock';
|
||||
import Container from 'typedi';
|
||||
import Csrf from 'csrf';
|
||||
import { type Response } from 'express';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { OAuth2CredentialController } from '@/controllers/oauth/oAuth2Credential.controller';
|
||||
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
||||
import type { User } from '@db/entities/User';
|
||||
import type { OAuthRequest } from '@/requests';
|
||||
import { CredentialsRepository } from '@db/repositories/credentials.repository';
|
||||
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
|
||||
import { ExternalHooks } from '@/ExternalHooks';
|
||||
import { Logger } from '@/Logger';
|
||||
import { VariablesService } from '@/environments/variables/variables.service.ee';
|
||||
import { SecretsHelper } from '@/SecretsHelpers';
|
||||
import { CredentialsHelper } from '@/CredentialsHelper';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
import { mockInstance } from '@test/mocking';
|
||||
|
||||
describe('OAuth2CredentialController', () => {
|
||||
mockInstance(Logger);
|
||||
mockInstance(SecretsHelper);
|
||||
mockInstance(VariablesService, {
|
||||
getAllCached: async () => [],
|
||||
});
|
||||
const cipher = mockInstance(Cipher);
|
||||
const externalHooks = mockInstance(ExternalHooks);
|
||||
const credentialsHelper = mockInstance(CredentialsHelper);
|
||||
const credentialsRepository = mockInstance(CredentialsRepository);
|
||||
const sharedCredentialsRepository = mockInstance(SharedCredentialsRepository);
|
||||
|
||||
const csrfSecret = 'csrf-secret';
|
||||
const user = mock<User>({
|
||||
id: '123',
|
||||
password: 'password',
|
||||
authIdentities: [],
|
||||
role: 'global:owner',
|
||||
});
|
||||
const credential = mock<CredentialsEntity>({
|
||||
id: '1',
|
||||
name: 'Test Credential',
|
||||
type: 'oAuth2Api',
|
||||
});
|
||||
|
||||
const controller = Container.get(OAuth2CredentialController);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('getAuthUri', () => {
|
||||
it('should throw a BadRequestError when credentialId is missing in the query', async () => {
|
||||
const req = mock<OAuthRequest.OAuth2Credential.Auth>({ query: { id: '' } });
|
||||
await expect(controller.getAuthUri(req)).rejects.toThrowError(
|
||||
new BadRequestError('Required credential ID is missing'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a NotFoundError when no matching credential is found for the user', async () => {
|
||||
sharedCredentialsRepository.findCredentialForUser.mockResolvedValueOnce(null);
|
||||
|
||||
const req = mock<OAuthRequest.OAuth2Credential.Auth>({ user, query: { id: '1' } });
|
||||
await expect(controller.getAuthUri(req)).rejects.toThrowError(
|
||||
new NotFoundError('Credential not found'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a valid auth URI', async () => {
|
||||
jest.spyOn(Csrf.prototype, 'secretSync').mockReturnValueOnce(csrfSecret);
|
||||
jest.spyOn(Csrf.prototype, 'create').mockReturnValueOnce('token');
|
||||
sharedCredentialsRepository.findCredentialForUser.mockResolvedValueOnce(credential);
|
||||
credentialsHelper.getDecrypted.mockResolvedValueOnce({});
|
||||
credentialsHelper.applyDefaultsAndOverwrites.mockReturnValue({
|
||||
clientId: 'test-client-id',
|
||||
authUrl: 'https://example.domain/o/oauth2/v2/auth',
|
||||
});
|
||||
cipher.encrypt.mockReturnValue('encrypted');
|
||||
|
||||
const req = mock<OAuthRequest.OAuth2Credential.Auth>({ user, query: { id: '1' } });
|
||||
const authUri = await controller.getAuthUri(req);
|
||||
expect(authUri).toEqual(
|
||||
'https://example.domain/o/oauth2/v2/auth?client_id=test-client-id&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback&response_type=code&state=eyJ0b2tlbiI6InRva2VuIiwiY2lkIjoiMSJ9&scope=openid',
|
||||
);
|
||||
expect(credentialsRepository.update).toHaveBeenCalledWith(
|
||||
'1',
|
||||
expect.objectContaining({
|
||||
data: 'encrypted',
|
||||
id: '1',
|
||||
name: 'Test Credential',
|
||||
type: 'oAuth2Api',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCallback', () => {
|
||||
const validState = Buffer.from(
|
||||
JSON.stringify({
|
||||
token: 'token',
|
||||
cid: '1',
|
||||
}),
|
||||
).toString('base64');
|
||||
|
||||
it('should render the error page when required query params are missing', async () => {
|
||||
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
|
||||
query: { code: undefined, state: undefined },
|
||||
});
|
||||
const res = mock<Response>();
|
||||
await controller.handleCallback(req, res);
|
||||
|
||||
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
|
||||
error: {
|
||||
message: 'Insufficient parameters for OAuth2 callback.',
|
||||
reason: 'Received following query parameters: undefined',
|
||||
},
|
||||
});
|
||||
expect(credentialsRepository.findOneBy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render the error page when `state` query param is invalid', async () => {
|
||||
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
|
||||
query: { code: 'code', state: 'invalid-state' },
|
||||
});
|
||||
const res = mock<Response>();
|
||||
await controller.handleCallback(req, res);
|
||||
|
||||
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
|
||||
error: {
|
||||
message: 'Invalid state format',
|
||||
},
|
||||
});
|
||||
expect(credentialsRepository.findOneBy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render the error page when credential is not found in DB', async () => {
|
||||
credentialsRepository.findOneBy.mockResolvedValueOnce(null);
|
||||
|
||||
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
|
||||
query: { code: 'code', state: validState },
|
||||
});
|
||||
const res = mock<Response>();
|
||||
await controller.handleCallback(req, res);
|
||||
|
||||
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
|
||||
error: {
|
||||
message: 'OAuth2 callback failed because of insufficient permissions',
|
||||
},
|
||||
});
|
||||
expect(credentialsRepository.findOneBy).toHaveBeenCalledTimes(1);
|
||||
expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({ id: '1' });
|
||||
});
|
||||
|
||||
it('should render the error page when csrfSecret on the saved credential does not match the state', async () => {
|
||||
credentialsRepository.findOneBy.mockResolvedValueOnce(credential);
|
||||
credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret });
|
||||
jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(false);
|
||||
|
||||
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
|
||||
query: { code: 'code', state: validState },
|
||||
});
|
||||
const res = mock<Response>();
|
||||
await controller.handleCallback(req, res);
|
||||
expect(res.render).toHaveBeenCalledWith('oauth-error-callback', {
|
||||
error: {
|
||||
message: 'The OAuth2 callback state is invalid!',
|
||||
},
|
||||
});
|
||||
expect(externalHooks.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should exchange the code for a valid token, and save it to DB', async () => {
|
||||
credentialsRepository.findOneBy.mockResolvedValueOnce(credential);
|
||||
credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret });
|
||||
credentialsHelper.applyDefaultsAndOverwrites.mockReturnValue({
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'oauth-secret',
|
||||
accessTokenUrl: 'https://example.domain/token',
|
||||
});
|
||||
jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true);
|
||||
nock('https://example.domain')
|
||||
.post(
|
||||
'/token',
|
||||
'code=code&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback',
|
||||
)
|
||||
.reply(200, { access_token: 'access-token', refresh_token: 'refresh-token' });
|
||||
cipher.encrypt.mockReturnValue('encrypted');
|
||||
|
||||
const req = mock<OAuthRequest.OAuth2Credential.Callback>({
|
||||
query: { code: 'code', state: validState },
|
||||
originalUrl: '?code=code',
|
||||
});
|
||||
const res = mock<Response>();
|
||||
await controller.handleCallback(req, res);
|
||||
|
||||
expect(externalHooks.run).toHaveBeenCalledWith('oauth2.callback', [
|
||||
expect.objectContaining({
|
||||
clientId: 'test-client-id',
|
||||
redirectUri: 'http://localhost:5678/rest/oauth2-credential/callback',
|
||||
}),
|
||||
]);
|
||||
expect(cipher.encrypt).toHaveBeenCalledWith({
|
||||
oauthTokenData: { access_token: 'access-token', refresh_token: 'refresh-token' },
|
||||
});
|
||||
expect(credentialsRepository.update).toHaveBeenCalledWith(
|
||||
'1',
|
||||
expect.objectContaining({
|
||||
data: 'encrypted',
|
||||
id: '1',
|
||||
name: 'Test Credential',
|
||||
type: 'oAuth2Api',
|
||||
}),
|
||||
);
|
||||
expect(res.render).toHaveBeenCalledWith('oauth-callback');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user