test(core): Move unit tests closer to testable components (no-changelog) (#10287)

This commit is contained in:
Tomi Turtiainen
2024-08-05 12:12:25 +03:00
committed by GitHub
parent 8131d66f8c
commit afa43e75f6
80 changed files with 95 additions and 105 deletions

View File

@@ -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,
});
});
});
});

View File

@@ -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);
});
});
});

View 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 });
});
});
});
});

View 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);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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!',
},
});
});
});
});

View File

@@ -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');
});
});
});