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,40 @@
import { Container } from 'typedi';
import { ExecutionMetadataRepository } from '@db/repositories/executionMetadata.repository';
import { ExecutionMetadataService } from '@/services/executionMetadata.service';
import { mockInstance } from '@test/mocking';
describe('ExecutionMetadataService', () => {
const repository = mockInstance(ExecutionMetadataRepository);
test('Execution metadata is saved in a batch', async () => {
const toSave = {
test1: 'value1',
test2: 'value2',
};
const executionId = '1234';
await Container.get(ExecutionMetadataService).save(executionId, toSave);
expect(repository.upsert).toHaveBeenCalledTimes(1);
expect(repository.upsert.mock.calls[0]).toEqual([
[
{
executionId,
key: 'test1',
value: 'value1',
},
{
executionId,
key: 'test2',
value: 'value2',
},
],
{
conflictPaths: {
executionId: true,
key: true,
},
},
]);
});
});

View File

@@ -0,0 +1,86 @@
import type { ActivationErrorsService } from '@/ActivationErrors.service';
import type { User } from '@db/entities/User';
import type { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import type { WorkflowRepository } from '@db/repositories/workflow.repository';
import { ActiveWorkflowsService } from '@/services/activeWorkflows.service';
import { mock } from 'jest-mock-extended';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
describe('ActiveWorkflowsService', () => {
const user = mock<User>();
const workflowRepository = mock<WorkflowRepository>();
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
const activationErrorsService = mock<ActivationErrorsService>();
const service = new ActiveWorkflowsService(
mock(),
workflowRepository,
sharedWorkflowRepository,
activationErrorsService,
);
const activeIds = ['1', '2', '3', '4'];
beforeEach(() => jest.clearAllMocks());
describe('getAllActiveIdsInStorage', () => {
it('should filter out any workflow ids that have activation errors', async () => {
activationErrorsService.getAll.mockResolvedValue({ 1: 'some error' });
workflowRepository.getActiveIds.mockResolvedValue(activeIds);
const ids = await service.getAllActiveIdsInStorage();
expect(ids).toEqual(['2', '3', '4']);
});
});
describe('getAllActiveIdsFor', () => {
beforeEach(() => {
activationErrorsService.getAll.mockResolvedValue({ 1: 'some error' });
workflowRepository.getActiveIds.mockResolvedValue(activeIds);
});
it('should return all workflow ids when user has full access', async () => {
user.hasGlobalScope.mockReturnValue(true);
const ids = await service.getAllActiveIdsFor(user);
expect(ids).toEqual(['2', '3', '4']);
expect(user.hasGlobalScope).toHaveBeenCalledWith('workflow:list');
expect(sharedWorkflowRepository.getSharedWorkflowIds).not.toHaveBeenCalled();
});
it('should filter out workflow ids that the user does not have access to', async () => {
user.hasGlobalScope.mockReturnValue(false);
sharedWorkflowRepository.getSharedWorkflowIds.mockResolvedValue(['3']);
const ids = await service.getAllActiveIdsFor(user);
expect(ids).toEqual(['3']);
expect(user.hasGlobalScope).toHaveBeenCalledWith('workflow:list');
expect(sharedWorkflowRepository.getSharedWorkflowIds).toHaveBeenCalledWith(activeIds);
});
});
describe('getActivationError', () => {
const workflowId = 'workflowId';
it('should throw a BadRequestError a user does not have access to the workflow id', async () => {
sharedWorkflowRepository.findWorkflowForUser.mockResolvedValue(null);
await expect(service.getActivationError(workflowId, user)).rejects.toThrow(BadRequestError);
expect(sharedWorkflowRepository.findWorkflowForUser).toHaveBeenCalledWith(workflowId, user, [
'workflow:read',
]);
expect(activationErrorsService.get).not.toHaveBeenCalled();
});
it('should return the error when the user has access', async () => {
sharedWorkflowRepository.findWorkflowForUser.mockResolvedValue(new WorkflowEntity());
activationErrorsService.get.mockResolvedValue('some-error');
const error = await service.getActivationError(workflowId, user);
expect(error).toEqual('some-error');
expect(sharedWorkflowRepository.findWorkflowForUser).toHaveBeenCalledWith(workflowId, user, [
'workflow:read',
]);
expect(activationErrorsService.get).toHaveBeenCalledWith(workflowId);
});
});
});

View File

@@ -0,0 +1,426 @@
import { exec } from 'child_process';
import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises';
import axios from 'axios';
import { mocked } from 'jest-mock';
import Container from 'typedi';
import type { PublicInstalledPackage } from 'n8n-workflow';
import {
NODE_PACKAGE_PREFIX,
NPM_COMMAND_TOKENS,
NPM_PACKAGE_STATUS_GOOD,
RESPONSE_ERROR_MESSAGES,
} from '@/constants';
import config from '@/config';
import { InstalledPackages } from '@db/entities/InstalledPackages';
import type { CommunityPackages } from '@/Interfaces';
import { CommunityPackagesService } from '@/services/communityPackages.service';
import { InstalledNodesRepository } from '@db/repositories/installedNodes.repository';
import { InstalledPackagesRepository } from '@db/repositories/installedPackages.repository';
import { InstalledNodes } from '@db/entities/InstalledNodes';
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
import { mockInstance } from '@test/mocking';
import { COMMUNITY_NODE_VERSION, COMMUNITY_PACKAGE_VERSION } from '@test-integration/constants';
import { randomName } from '@test-integration/random';
import { mockPackageName, mockPackagePair } from '@test-integration/utils';
import { InstanceSettings, PackageDirectoryLoader } from 'n8n-core';
import { Logger } from '@/Logger';
jest.mock('fs/promises');
jest.mock('child_process');
jest.mock('axios');
type ExecOptions = NonNullable<Parameters<typeof exec>[1]>;
type ExecCallback = NonNullable<Parameters<typeof exec>[2]>;
const execMock = ((...args) => {
const cb = args[args.length - 1] as ExecCallback;
cb(null, 'Done', '');
}) as typeof exec;
describe('CommunityPackagesService', () => {
const installedNodesRepository = mockInstance(InstalledNodesRepository);
installedNodesRepository.create.mockImplementation(() => {
const nodeName = randomName();
return Object.assign(new InstalledNodes(), {
name: nodeName,
type: nodeName,
latestVersion: COMMUNITY_NODE_VERSION.CURRENT.toString(),
packageName: 'test',
});
});
const installedPackageRepository = mockInstance(InstalledPackagesRepository);
installedPackageRepository.create.mockImplementation(() => {
return Object.assign(new InstalledPackages(), {
packageName: mockPackageName(),
installedVersion: COMMUNITY_PACKAGE_VERSION.CURRENT,
});
});
mockInstance(LoadNodesAndCredentials);
const communityPackagesService = Container.get(CommunityPackagesService);
beforeEach(() => {
config.load(config.default);
});
describe('parseNpmPackageName()', () => {
test('should fail with empty package name', () => {
expect(() => communityPackagesService.parseNpmPackageName('')).toThrowError();
});
test('should fail with invalid package prefix name', () => {
expect(() =>
communityPackagesService.parseNpmPackageName('INVALID_PREFIX@123'),
).toThrowError();
});
test('should parse valid package name', () => {
const name = mockPackageName();
const parsed = communityPackagesService.parseNpmPackageName(name);
expect(parsed.rawString).toBe(name);
expect(parsed.packageName).toBe(name);
expect(parsed.scope).toBeUndefined();
expect(parsed.version).toBeUndefined();
});
test('should parse valid package name and version', () => {
const name = mockPackageName();
const version = '0.1.1';
const fullPackageName = `${name}@${version}`;
const parsed = communityPackagesService.parseNpmPackageName(fullPackageName);
expect(parsed.rawString).toBe(fullPackageName);
expect(parsed.packageName).toBe(name);
expect(parsed.scope).toBeUndefined();
expect(parsed.version).toBe(version);
});
test('should parse valid package name, scope and version', () => {
const scope = '@n8n';
const name = mockPackageName();
const version = '0.1.1';
const fullPackageName = `${scope}/${name}@${version}`;
const parsed = communityPackagesService.parseNpmPackageName(fullPackageName);
expect(parsed.rawString).toBe(fullPackageName);
expect(parsed.packageName).toBe(`${scope}/${name}`);
expect(parsed.scope).toBe(scope);
expect(parsed.version).toBe(version);
});
});
describe('executeCommand()', () => {
beforeEach(() => {
mocked(fsAccess).mockReset();
mocked(fsMkdir).mockReset();
mocked(exec).mockReset();
});
test('should call command with valid options', async () => {
const execMock = ((...args) => {
const arg = args[1] as ExecOptions;
expect(arg.cwd).toBeDefined();
expect(arg.env).toBeDefined();
// PATH or NODE_PATH may be undefined depending on environment so we don't check for these keys.
const cb = args[args.length - 1] as ExecCallback;
cb(null, 'Done', '');
}) as typeof exec;
mocked(exec).mockImplementation(execMock);
await communityPackagesService.executeNpmCommand('ls');
expect(fsAccess).toHaveBeenCalled();
expect(exec).toHaveBeenCalled();
expect(fsMkdir).toBeCalledTimes(0);
});
test('should make sure folder exists', async () => {
mocked(exec).mockImplementation(execMock);
await communityPackagesService.executeNpmCommand('ls');
expect(fsAccess).toHaveBeenCalled();
expect(exec).toHaveBeenCalled();
expect(fsMkdir).toBeCalledTimes(0);
});
test('should try to create folder if it does not exist', async () => {
mocked(exec).mockImplementation(execMock);
mocked(fsAccess).mockImplementation(() => {
throw new Error('Folder does not exist.');
});
await communityPackagesService.executeNpmCommand('ls');
expect(fsAccess).toHaveBeenCalled();
expect(exec).toHaveBeenCalled();
expect(fsMkdir).toHaveBeenCalled();
});
test('should throw especial error when package is not found', async () => {
const erroringExecMock = ((...args) => {
const cb = args[args.length - 1] as ExecCallback;
const msg = `Something went wrong - ${NPM_COMMAND_TOKENS.NPM_PACKAGE_NOT_FOUND_ERROR}. Aborting.`;
cb(new Error(msg), '', '');
}) as typeof exec;
mocked(exec).mockImplementation(erroringExecMock);
const call = async () => await communityPackagesService.executeNpmCommand('ls');
await expect(call).rejects.toThrowError(RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND);
expect(fsAccess).toHaveBeenCalled();
expect(exec).toHaveBeenCalled();
expect(fsMkdir).toHaveBeenCalledTimes(0);
});
});
describe('crossInformationPackage()', () => {
test('should return same list if availableUpdates is undefined', () => {
const fakePkgs = mockPackagePair();
const crossedPkgs = communityPackagesService.matchPackagesWithUpdates(fakePkgs);
expect(crossedPkgs).toEqual(fakePkgs);
});
test('should correctly match update versions for packages', () => {
const [pkgA, pkgB] = mockPackagePair();
const updates: CommunityPackages.AvailableUpdates = {
[pkgA.packageName]: {
current: pkgA.installedVersion,
wanted: pkgA.installedVersion,
latest: '0.2.0',
location: pkgA.packageName,
},
[pkgB.packageName]: {
current: pkgA.installedVersion,
wanted: pkgA.installedVersion,
latest: '0.3.0',
location: pkgA.packageName,
},
};
const [crossedPkgA, crossedPkgB]: PublicInstalledPackage[] =
communityPackagesService.matchPackagesWithUpdates([pkgA, pkgB], updates);
expect(crossedPkgA.updateAvailable).toBe('0.2.0');
expect(crossedPkgB.updateAvailable).toBe('0.3.0');
});
test('should correctly match update versions for single package', () => {
const [pkgA, pkgB] = mockPackagePair();
const updates: CommunityPackages.AvailableUpdates = {
[pkgB.packageName]: {
current: pkgA.installedVersion,
wanted: pkgA.installedVersion,
latest: '0.3.0',
location: pkgA.packageName,
},
};
const [crossedPkgA, crossedPkgB]: PublicInstalledPackage[] =
communityPackagesService.matchPackagesWithUpdates([pkgA, pkgB], updates);
expect(crossedPkgA.updateAvailable).toBeUndefined();
expect(crossedPkgB.updateAvailable).toBe('0.3.0');
});
});
describe('matchMissingPackages()', () => {
test('should not match failed packages that do not exist', () => {
const fakePkgs = mockPackagePair();
setMissingPackages([
`${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0`,
`${NODE_PACKAGE_PREFIX}another-very-long-name-that-never-is-seen`,
]);
const matchedPackages = communityPackagesService.matchMissingPackages(fakePkgs);
expect(matchedPackages).toEqual(fakePkgs);
const [first, second] = matchedPackages;
expect(first.failedLoading).toBeUndefined();
expect(second.failedLoading).toBeUndefined();
});
test('should match failed packages that should be present', () => {
const [pkgA, pkgB] = mockPackagePair();
setMissingPackages([
`${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0`,
`${pkgA.packageName}@${pkgA.installedVersion}`,
]);
const [matchedPkgA, matchedPkgB] = communityPackagesService.matchMissingPackages([
pkgA,
pkgB,
]);
expect(matchedPkgA.failedLoading).toBe(true);
expect(matchedPkgB.failedLoading).toBeUndefined();
});
test('should match failed packages even if version is wrong', () => {
const [pkgA, pkgB] = mockPackagePair();
setMissingPackages([
`${NODE_PACKAGE_PREFIX}very-long-name-that-should-never-be-generated@1.0.0`,
`${pkgA.packageName}@123.456.789`,
]);
const [matchedPkgA, matchedPkgB] = communityPackagesService.matchMissingPackages([
pkgA,
pkgB,
]);
expect(matchedPkgA.failedLoading).toBe(true);
expect(matchedPkgB.failedLoading).toBeUndefined();
});
});
describe('checkNpmPackageStatus()', () => {
test('should call axios.post', async () => {
await communityPackagesService.checkNpmPackageStatus(mockPackageName());
expect(axios.post).toHaveBeenCalled();
});
test('should not fail if request fails', async () => {
mocked(axios.post).mockImplementation(() => {
throw new Error('Something went wrong');
});
const result = await communityPackagesService.checkNpmPackageStatus(mockPackageName());
expect(result.status).toBe(NPM_PACKAGE_STATUS_GOOD);
});
test('should warn if package is banned', async () => {
mocked(axios.post).mockResolvedValue({ data: { status: 'Banned', reason: 'Not good' } });
const result = (await communityPackagesService.checkNpmPackageStatus(
mockPackageName(),
)) as CommunityPackages.PackageStatusCheck;
expect(result.status).toBe('Banned');
expect(result.reason).toBe('Not good');
});
});
describe('hasPackageLoadedSuccessfully()', () => {
test('should return true when failed package list does not exist', () => {
setMissingPackages([]);
expect(communityPackagesService.hasPackageLoaded('package')).toBe(true);
});
test('should return true when package is not in the list of missing packages', () => {
setMissingPackages(['packageA@0.1.0', 'packageB@0.1.0']);
expect(communityPackagesService.hasPackageLoaded('packageC')).toBe(true);
});
test('should return false when package is in the list of missing packages', () => {
setMissingPackages(['packageA@0.1.0', 'packageB@0.1.0']);
expect(communityPackagesService.hasPackageLoaded('packageA')).toBe(false);
});
});
describe('removePackageFromMissingList()', () => {
test('should do nothing if key does not exist', () => {
setMissingPackages([]);
communityPackagesService.removePackageFromMissingList('packageA');
expect(communityPackagesService.missingPackages).toBeEmptyArray();
});
test('should remove only correct package from list', () => {
setMissingPackages(['packageA@0.1.0', 'packageB@0.2.0', 'packageC@0.2.0']);
communityPackagesService.removePackageFromMissingList('packageB');
expect(communityPackagesService.missingPackages).toEqual([
'packageA@0.1.0',
'packageC@0.2.0',
]);
});
test('should not remove if package is not in the list', () => {
const failedToLoadList = ['packageA@0.1.0', 'packageB@0.2.0', 'packageB@0.2.0'];
setMissingPackages(failedToLoadList);
communityPackagesService.removePackageFromMissingList('packageC');
expect(communityPackagesService.missingPackages).toEqual(failedToLoadList);
});
});
const setMissingPackages = (missingPackages: string[]) => {
Object.assign(communityPackagesService, { missingPackages });
};
describe('updateNpmModule', () => {
let packageDirectoryLoader: PackageDirectoryLoader;
let communityPackagesService: CommunityPackagesService;
beforeEach(async () => {
jest.restoreAllMocks();
packageDirectoryLoader = mockInstance(PackageDirectoryLoader);
const loadNodesAndCredentials = mockInstance(LoadNodesAndCredentials);
loadNodesAndCredentials.loadPackage.mockResolvedValue(packageDirectoryLoader);
const instanceSettings = mockInstance(InstanceSettings);
const logger = mockInstance(Logger);
const installedPackagesRepository = mockInstance(InstalledPackagesRepository);
communityPackagesService = new CommunityPackagesService(
instanceSettings,
logger,
installedPackagesRepository,
loadNodesAndCredentials,
);
});
afterEach(async () => {
jest.restoreAllMocks();
});
test('should call `exec` with the correct command ', async () => {
//
// ARRANGE
//
const nodeName = randomName();
packageDirectoryLoader.loadedNodes = [{ name: nodeName, version: 1 }];
const installedPackage = new InstalledPackages();
installedPackage.packageName = mockPackageName();
mocked(exec).mockImplementation(execMock);
//
// ACT
//
await communityPackagesService.updateNpmModule(
installedPackage.packageName,
installedPackage,
);
//
// ASSERT
//
expect(exec).toHaveBeenCalledTimes(1);
expect(exec).toHaveBeenNthCalledWith(
1,
`npm install ${installedPackage.packageName}@latest`,
expect.any(Object),
expect.any(Function),
);
});
});
});

View File

@@ -0,0 +1,32 @@
import { CredentialsTester } from '@/services/credentials-tester.service';
import mock from 'jest-mock-extended/lib/Mock';
import type { CredentialTypes } from '@/CredentialTypes';
import type { ICredentialType, INodeType } from 'n8n-workflow';
import type { NodeTypes } from '@/NodeTypes';
describe('CredentialsTester', () => {
const credentialTypes = mock<CredentialTypes>();
const nodeTypes = mock<NodeTypes>();
const credentialsTester = new CredentialsTester(mock(), credentialTypes, nodeTypes, mock());
beforeEach(() => {
jest.clearAllMocks();
});
it('should find the OAuth2 credential test for a generic OAuth2 API credential', () => {
credentialTypes.getByName.mockReturnValue(mock<ICredentialType>({ test: undefined }));
credentialTypes.getSupportedNodes.mockReturnValue(['oAuth2Api']);
credentialTypes.getParentTypes.mockReturnValue([]);
nodeTypes.getByName.mockReturnValue(
mock<INodeType>({
description: { credentials: [{ name: 'oAuth2Api' }] },
}),
);
const testFn = credentialsTester.getCredentialTestFunction('oAuth2Api');
if (typeof testFn !== 'function') fail();
expect(testFn.name).toBe('oauth2CredTest');
});
});

View File

@@ -0,0 +1,297 @@
import { CurlService } from '@/services/curl.service';
describe('CurlService', () => {
const service = new CurlService();
test('Should parse form-urlencoded content type correctly', () => {
const curl =
'curl -X POST https://reqbin.com/echo/post/form -H "Content-Type: application/x-www-form-urlencoded" -d "param1=value1&param2=value2"';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo/post/form');
expect(parameters.sendBody).toBe(true);
expect(parameters.bodyParameters?.parameters[0].name).toBe('param1');
expect(parameters.bodyParameters?.parameters[0].value).toBe('value1');
expect(parameters.bodyParameters?.parameters[1].name).toBe('param2');
expect(parameters.bodyParameters?.parameters[1].value).toBe('value2');
expect(parameters.contentType).toBe('form-urlencoded');
expect(parameters.sendHeaders).toBe(false);
expect(parameters.sendQuery).toBe(false);
});
test('Should parse JSON content type correctly', () => {
const curl =
'curl -X POST https://reqbin.com/echo/post/json -H \'Content-Type: application/json\' -d \'{"login":"my_login","password":"my_password"}\'';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo/post/json');
expect(parameters.sendBody).toBe(true);
expect(parameters.bodyParameters?.parameters[0].name).toBe('login');
expect(parameters.bodyParameters?.parameters[0].value).toBe('my_login');
expect(parameters.bodyParameters?.parameters[1].name).toBe('password');
expect(parameters.bodyParameters?.parameters[1].value).toBe('my_password');
expect(parameters.contentType).toBe('json');
expect(parameters.sendHeaders).toBe(false);
expect(parameters.sendQuery).toBe(false);
});
test('Should parse multipart-form-data content type correctly', () => {
const curl =
'curl -X POST https://reqbin.com/echo/post/json -v -F key1=value1 -F upload=@localfilename';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo/post/json');
expect(parameters.sendBody).toBe(true);
expect(parameters.bodyParameters?.parameters[0].parameterType).toBe('formData');
expect(parameters.bodyParameters?.parameters[0].name).toBe('key1');
expect(parameters.bodyParameters?.parameters[0].value).toBe('value1');
expect(parameters.bodyParameters?.parameters[1].parameterType).toBe('formBinaryData');
expect(parameters.bodyParameters?.parameters[1].name).toBe('upload');
expect(parameters.contentType).toBe('multipart-form-data');
expect(parameters.sendHeaders).toBe(false);
expect(parameters.sendQuery).toBe(false);
});
test('Should parse binary request correctly', () => {
const curl =
"curl --location --request POST 'https://www.website.com' --header 'Content-Type: image/png' --data-binary '@/Users/image.png";
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://www.website.com');
expect(parameters.method).toBe('POST');
expect(parameters.sendBody).toBe(true);
expect(parameters.contentType).toBe('binaryData');
expect(parameters.sendHeaders).toBe(false);
expect(parameters.sendQuery).toBe(false);
});
test('Should parse unknown content type correctly', () => {
const curl = `curl -X POST https://reqbin.com/echo/post/xml
-H "Content-Type: application/xml"
-H "Accept: application/xml"
-d "<Request><Login>my_login</Login><Password>my_password</Password></Request>"`;
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo/post/xml');
expect(parameters.method).toBe('POST');
expect(parameters.sendBody).toBe(true);
expect(parameters.contentType).toBe('raw');
expect(parameters.rawContentType).toBe('application/xml');
expect(parameters.sendHeaders).toBe(true);
expect(parameters.headerParameters?.parameters[0].name).toBe('Accept');
expect(parameters.headerParameters?.parameters[0].value).toBe('application/xml');
expect(parameters.sendQuery).toBe(false);
});
test('Should parse header properties and keep the original case', () => {
const curl =
'curl -X POST https://reqbin.com/echo/post/json -v -F key1=value1 -F upload=@localfilename -H "ACCEPT: text/javascript" -H "content-type: multipart/form-data"';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo/post/json');
expect(parameters.sendBody).toBe(true);
expect(parameters.bodyParameters?.parameters[0].parameterType).toBe('formData');
expect(parameters.bodyParameters?.parameters[0].name).toBe('key1');
expect(parameters.bodyParameters?.parameters[0].value).toBe('value1');
expect(parameters.bodyParameters?.parameters[1].parameterType).toBe('formBinaryData');
expect(parameters.bodyParameters?.parameters[1].name).toBe('upload');
expect(parameters.contentType).toBe('multipart-form-data');
expect(parameters.sendHeaders).toBe(true);
expect(parameters.headerParameters?.parameters[0].name).toBe('ACCEPT');
expect(parameters.headerParameters?.parameters[0].value).toBe('text/javascript');
expect(parameters.sendQuery).toBe(false);
});
test('Should parse querystring properties', () => {
const curl = "curl -G -d 'q=kitties' -d 'count=20' https://google.com/search";
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://google.com/search');
expect(parameters.sendBody).toBe(false);
expect(parameters.contentType).toBeUndefined();
expect(parameters.sendHeaders).toBe(false);
expect(parameters.sendQuery).toBe(true);
expect(parameters.queryParameters?.parameters[0].name).toBe('q');
expect(parameters.queryParameters?.parameters[0].value).toBe('kitties');
expect(parameters.queryParameters?.parameters[1].name).toBe('count');
expect(parameters.queryParameters?.parameters[1].value).toBe('20');
});
test('Should parse basic authentication property and keep the original case', () => {
const curl = 'curl https://reqbin.com/echo -u "login:password"';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo');
expect(parameters.sendBody).toBe(false);
expect(parameters.contentType).toBeUndefined();
expect(parameters.sendQuery).toBe(false);
expect(parameters.sendHeaders).toBe(true);
expect(parameters.headerParameters?.parameters[0].name).toBe('authorization');
expect(parameters.headerParameters?.parameters[0].value).toBe(
`Basic ${Buffer.from('login:password').toString('base64')}`,
);
});
test('Should parse location flag with --location', () => {
const curl = 'curl https://reqbin.com/echo -u "login:password" --location';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo');
expect(parameters.sendBody).toBe(false);
expect(parameters.contentType).toBeUndefined();
expect(parameters.sendQuery).toBe(false);
expect(parameters.sendHeaders).toBe(true);
expect(parameters.headerParameters?.parameters[0].name).toBe('authorization');
expect(parameters.headerParameters?.parameters[0].value).toBe(
`Basic ${Buffer.from('login:password').toString('base64')}`,
);
expect(parameters.options.redirect.redirect.followRedirects).toBe(true);
});
test('Should parse location flag with --L', () => {
const curl = 'curl https://reqbin.com/echo -u "login:password" -L';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo');
expect(parameters.sendBody).toBe(false);
expect(parameters.contentType).toBeUndefined();
expect(parameters.sendQuery).toBe(false);
expect(parameters.sendHeaders).toBe(true);
expect(parameters.headerParameters?.parameters[0].name).toBe('authorization');
expect(parameters.headerParameters?.parameters[0].value).toBe(
`Basic ${Buffer.from('login:password').toString('base64')}`,
);
expect(parameters.options.redirect.redirect.followRedirects).toBe(true);
});
test('Should parse location and max redirects flags with --location and --max-redirs 10', () => {
const curl = 'curl https://reqbin.com/echo -u "login:password" --location --max-redirs 10';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo');
expect(parameters.sendBody).toBe(false);
expect(parameters.contentType).toBeUndefined();
expect(parameters.sendQuery).toBe(false);
expect(parameters.sendHeaders).toBe(true);
expect(parameters.headerParameters?.parameters[0].name).toBe('authorization');
expect(parameters.headerParameters?.parameters[0].value).toBe(
`Basic ${Buffer.from('login:password').toString('base64')}`,
);
expect(parameters.options.redirect.redirect.followRedirects).toBe(true);
expect(parameters.options.redirect.redirect.maxRedirects).toBe('10');
});
test('Should parse proxy flag -x', () => {
const curl = 'curl https://reqbin.com/echo -u "login:password" -x https://google.com';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo');
expect(parameters.sendBody).toBe(false);
expect(parameters.contentType).toBeUndefined();
expect(parameters.sendQuery).toBe(false);
expect(parameters.sendHeaders).toBe(true);
expect(parameters.headerParameters?.parameters[0].name).toBe('authorization');
expect(parameters.headerParameters?.parameters[0].value).toBe(
`Basic ${Buffer.from('login:password').toString('base64')}`,
);
expect(parameters.options.proxy).toBe('https://google.com');
});
test('Should parse proxy flag --proxy', () => {
const curl = 'curl https://reqbin.com/echo -u "login:password" -x https://google.com';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo');
expect(parameters.sendBody).toBe(false);
expect(parameters.contentType).toBeUndefined();
expect(parameters.sendQuery).toBe(false);
expect(parameters.sendHeaders).toBe(true);
expect(parameters.headerParameters?.parameters[0].name).toBe('authorization');
expect(parameters.headerParameters?.parameters[0].value).toBe(
`Basic ${Buffer.from('login:password').toString('base64')}`,
);
expect(parameters.options.proxy).toBe('https://google.com');
});
test('Should parse include headers on output flag --include', () => {
const curl = 'curl https://reqbin.com/echo -u "login:password" --include -x https://google.com';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo');
expect(parameters.sendBody).toBe(false);
expect(parameters.contentType).toBeUndefined();
expect(parameters.sendQuery).toBe(false);
expect(parameters.sendHeaders).toBe(true);
expect(parameters.headerParameters?.parameters[0].name).toBe('authorization');
expect(parameters.headerParameters?.parameters[0].value).toBe(
`Basic ${Buffer.from('login:password').toString('base64')}`,
);
expect(parameters.options.response.response.fullResponse).toBe(true);
});
test('Should parse include headers on output flag -i', () => {
const curl = 'curl https://reqbin.com/echo -u "login:password" -x https://google.com -i';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo');
expect(parameters.sendBody).toBe(false);
expect(parameters.contentType).toBeUndefined();
expect(parameters.sendQuery).toBe(false);
expect(parameters.sendHeaders).toBe(true);
expect(parameters.headerParameters?.parameters[0].name).toBe('authorization');
expect(parameters.headerParameters?.parameters[0].value).toBe(
`Basic ${Buffer.from('login:password').toString('base64')}`,
);
expect(parameters.options.response.response.fullResponse).toBe(true);
});
test('Should parse include request flag -X', () => {
const curl = 'curl -X POST https://reqbin.com/echo -u "login:password" -x https://google.com';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo');
expect(parameters.method).toBe('POST');
expect(parameters.sendBody).toBe(false);
});
test('Should parse include request flag --request', () => {
const curl =
'curl --request POST https://reqbin.com/echo -u "login:password" -x https://google.com';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo');
expect(parameters.method).toBe('POST');
expect(parameters.sendBody).toBe(false);
});
test('Should parse include timeout flag --connect-timeout', () => {
const curl =
'curl --request POST https://reqbin.com/echo -u "login:password" --connect-timeout 20';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo');
expect(parameters.method).toBe('POST');
expect(parameters.sendBody).toBe(false);
expect(parameters.options.timeout).toBe(20000);
});
test('Should parse download file flag -O', () => {
const curl = 'curl --request POST https://reqbin.com/echo -u "login:password" -O';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo');
expect(parameters.method).toBe('POST');
expect(parameters.sendBody).toBe(false);
expect(parameters.options.response.response.responseFormat).toBe('file');
expect(parameters.options.response.response.outputPropertyName).toBe('data');
});
test('Should parse download file flag -o', () => {
const curl = 'curl --request POST https://reqbin.com/echo -u "login:password" -o';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo');
expect(parameters.method).toBe('POST');
expect(parameters.sendBody).toBe(false);
expect(parameters.options.response.response.responseFormat).toBe('file');
expect(parameters.options.response.response.outputPropertyName).toBe('data');
});
test('Should parse ignore SSL flag -k', () => {
const curl = 'curl --request POST https://reqbin.com/echo -u "login:password" -k';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo');
expect(parameters.method).toBe('POST');
expect(parameters.sendBody).toBe(false);
expect(parameters.options.allowUnauthorizedCerts).toBe(true);
});
test('Should parse ignore SSL flag --insecure', () => {
const curl = 'curl --request POST https://reqbin.com/echo -u "login:password" --insecure';
const parameters = service.toHttpNodeParameters(curl);
expect(parameters.url).toBe('https://reqbin.com/echo');
expect(parameters.method).toBe('POST');
expect(parameters.sendBody).toBe(false);
expect(parameters.options.allowUnauthorizedCerts).toBe(true);
});
});

View File

@@ -0,0 +1,164 @@
import type { Response } from 'express';
import { mock } from 'jest-mock-extended';
import type { AuthUser } from '@db/entities/AuthUser';
import type { CredentialsRepository } from '@db/repositories/credentials.repository';
import type { SettingsRepository } from '@db/repositories/settings.repository';
import type { UserRepository } from '@db/repositories/user.repository';
import type { WorkflowRepository } from '@db/repositories/workflow.repository';
import type { AuthService } from '@/auth/auth.service';
import type { UserService } from '@/services/user.service';
import { HooksService } from '@/services/hooks.service';
import type { Invitation } from '@/Interfaces';
import type { AuthenticatedRequest } from '@/requests';
import type { AuthUserRepository } from '@/databases/repositories/authUser.repository';
import RudderStack from '@rudderstack/rudder-sdk-node';
jest.mock('@rudderstack/rudder-sdk-node');
describe('HooksService', () => {
const mockedUser = mock<AuthUser>();
const userService = mock<UserService>();
const authService = mock<AuthService>();
const userRepository = mock<UserRepository>();
const settingsRepository = mock<SettingsRepository>();
const workflowRepository = mock<WorkflowRepository>();
const credentialsRepository = mock<CredentialsRepository>();
const authUserRepository = mock<AuthUserRepository>();
const hooksService = new HooksService(
userService,
authService,
userRepository,
settingsRepository,
workflowRepository,
credentialsRepository,
authUserRepository,
);
beforeEach(() => {
jest.clearAllMocks();
});
it('hooksService.inviteUsers should call userService.inviteUsers', async () => {
// ARRANGE
const usersToInvite: Invitation[] = [{ email: 'test@n8n.io', role: 'global:member' }];
// ACT
await hooksService.inviteUsers(mockedUser, usersToInvite);
// ASSERT
expect(userService.inviteUsers).toHaveBeenCalledWith(mockedUser, usersToInvite);
});
it('hooksService.issueCookie should call authService.issueCookie', async () => {
// ARRANGE
const res = mock<Response>();
// ACT
hooksService.issueCookie(res, mockedUser);
// ASSERT
expect(authService.issueCookie).toHaveBeenCalledWith(res, mockedUser);
});
it('hooksService.findOneUser should call authUserRepository.findOne', async () => {
// ARRANGE
const filter = { where: { id: '1' } };
// ACT
await hooksService.findOneUser(filter);
// ASSERT
expect(authUserRepository.findOne).toHaveBeenCalledWith(filter);
});
it('hooksService.saveUser should call userRepository.save', async () => {
// ACT
await hooksService.saveUser(mockedUser);
// ASSERT
expect(userRepository.save).toHaveBeenCalledWith(mockedUser);
});
it('hooksService.updateSettings should call settingRepository.update', async () => {
// ARRANGE
const filter = { key: 'test' };
const set = { value: 'true' };
// ACT
await hooksService.updateSettings(filter, set);
// ASSERT
expect(settingsRepository.update).toHaveBeenCalledWith(filter, set);
});
it('hooksService.workflowsCount should call workflowRepository.count', async () => {
// ARRANGE
const filter = { where: { active: true } };
// ACT
await hooksService.workflowsCount(filter);
// ASSERT
expect(workflowRepository.count).toHaveBeenCalledWith(filter);
});
it('hooksService.credentialsCount should call credentialRepository.count', async () => {
// ARRANGE
const filter = { where: {} };
// ACT
await hooksService.credentialsCount(filter);
// ASSERT
expect(credentialsRepository.count).toHaveBeenCalledWith(filter);
});
it('hooksService.settingsCount should call settingsRepository.count', async () => {
// ARRANGE
const filter = { where: { key: 'test' } };
// ACT
await hooksService.settingsCount(filter);
// ASSERT
expect(settingsRepository.count).toHaveBeenCalledWith(filter);
});
it('hooksService.authMiddleware should call authService.authMiddleware', async () => {
// ARRANGE
const res = mock<Response>();
const req = mock<AuthenticatedRequest>();
const next = jest.fn();
// ACT
await hooksService.authMiddleware(req, res, next);
// ASSERT
expect(authService.authMiddleware).toHaveBeenCalledWith(req, res, next);
});
it('hooksService.dbCollections should return valid repositories', async () => {
// ACT
const collections = hooksService.dbCollections();
// ASSERT
expect(collections).toHaveProperty('User');
expect(collections).toHaveProperty('Settings');
expect(collections).toHaveProperty('Credentials');
expect(collections).toHaveProperty('Workflow');
});
it('hooksService.getRudderStackClient', async () => {
// ACT
const key = 'TEST';
const opts = { dataPlaneUrl: 'test.com' };
const client = hooksService.getRudderStackClient(key, opts);
expect(client instanceof RudderStack).toBeTruthy();
expect(RudderStack).toHaveBeenCalledWith(key, opts);
});
});

View File

@@ -0,0 +1,62 @@
import jwt from 'jsonwebtoken';
import type { InstanceSettings } from 'n8n-core';
import { mock } from 'jest-mock-extended';
import config from '@/config';
import { JwtService } from '@/services/jwt.service';
describe('JwtService', () => {
const iat = 1699984313;
const jwtSecret = 'random-string';
const payload = { sub: 1 };
const signedToken =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImlhdCI6MTY5OTk4NDMxM30.xNZOAmcidW5ovEF_mwIOzCWkJ70FEO6MFNLK2QRDOeQ';
const instanceSettings = mock<InstanceSettings>({ encryptionKey: 'test-key' });
beforeEach(() => {
jest.clearAllMocks();
});
describe('secret initialization', () => {
it('should read the secret from config, when set', () => {
config.set('userManagement.jwtSecret', jwtSecret);
const jwtService = new JwtService(instanceSettings);
expect(jwtService.jwtSecret).toEqual(jwtSecret);
});
it('should derive the secret from encryption key when not set in config', () => {
config.set('userManagement.jwtSecret', '');
const jwtService = new JwtService(instanceSettings);
expect(jwtService.jwtSecret).toEqual(
'e9e2975005eddefbd31b2c04a0b0f2d9c37d9d718cf3676cddf76d65dec555cb',
);
});
});
describe('with a secret set', () => {
config.set('userManagement.jwtSecret', jwtSecret);
const jwtService = new JwtService(instanceSettings);
beforeAll(() => {
jest.useFakeTimers().setSystemTime(new Date(iat * 1000));
});
afterAll(() => jest.useRealTimers());
it('should sign', () => {
const token = jwtService.sign(payload);
expect(token).toEqual(signedToken);
});
it('should decode and verify payload', () => {
const decodedToken = jwtService.verify(signedToken);
expect(decodedToken.sub).toEqual(1);
expect(decodedToken.iat).toEqual(iat);
});
it('should throw an error on verify if the token is expired', () => {
const expiredToken = jwt.sign(payload, jwtSecret, { expiresIn: -10 });
expect(() => jwtService.verify(expiredToken)).toThrow(jwt.TokenExpiredError);
});
});
});

View File

@@ -0,0 +1,87 @@
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { mockInstance } from '@test/mocking';
import { NamingService } from '@/services/naming.service';
import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
import type { CredentialsEntity } from '@/databases/entities/CredentialsEntity';
describe('NamingService', () => {
const workflowRepository = mockInstance(WorkflowRepository);
const credentialsRepository = mockInstance(CredentialsRepository);
const namingService = new NamingService(workflowRepository, credentialsRepository);
beforeEach(() => {
jest.restoreAllMocks();
});
describe('getUniqueWorkflowName()', () => {
test('should return requested name if already unique', async () => {
workflowRepository.findStartingWith.mockResolvedValue([]);
const name = await namingService.getUniqueWorkflowName('foo');
expect(name).toEqual('foo');
});
test('should return requested name suffixed if already existing once', async () => {
workflowRepository.findStartingWith.mockResolvedValue([{ name: 'foo' }] as WorkflowEntity[]);
const name = await namingService.getUniqueWorkflowName('foo');
expect(name).toEqual('foo 2');
});
test('should return requested name with incremented suffix if already suffixed', async () => {
const existingNames = [{ name: 'foo' }, { name: 'foo 2' }] as WorkflowEntity[];
workflowRepository.findStartingWith.mockResolvedValue(existingNames);
const name = await namingService.getUniqueWorkflowName('foo');
expect(name).toEqual('foo 3');
existingNames.push({ name: 'foo 3' } as WorkflowEntity);
const _name = await namingService.getUniqueWorkflowName('foo');
expect(_name).toEqual('foo 4');
});
});
describe('getUniqueCredentialName()', () => {
test('should return requested name if already unique', async () => {
credentialsRepository.findStartingWith.mockResolvedValue([]);
const name = await namingService.getUniqueCredentialName('foo');
expect(name).toEqual('foo');
});
test('should return requested name suffixed if already existing once', async () => {
credentialsRepository.findStartingWith.mockResolvedValue([
{ name: 'foo' },
] as CredentialsEntity[]);
const name = await namingService.getUniqueCredentialName('foo');
expect(name).toEqual('foo 2');
});
test('should return requested name with incremented suffix if already suffixed', async () => {
const existingNames = [{ name: 'foo' }, { name: 'foo 2' }] as CredentialsEntity[];
credentialsRepository.findStartingWith.mockResolvedValue(existingNames);
const name = await namingService.getUniqueCredentialName('foo');
expect(name).toEqual('foo 3');
existingNames.push({ name: 'foo 3' } as CredentialsEntity);
const _name = await namingService.getUniqueCredentialName('foo');
expect(_name).toEqual('foo 4');
});
});
});

View File

@@ -0,0 +1,184 @@
import Container from 'typedi';
import type Redis from 'ioredis';
import { mock } from 'jest-mock-extended';
import { InstanceSettings } from 'n8n-core';
import type { WorkflowActivateMode } from 'n8n-workflow';
import config from '@/config';
import { OrchestrationService } from '@/services/orchestration.service';
import type { RedisServiceWorkerResponseObject } from '@/services/redis/RedisServiceCommands';
import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus';
import { RedisService } from '@/services/redis.service';
import { handleWorkerResponseMessageMain } from '@/services/orchestration/main/handleWorkerResponseMessageMain';
import { handleCommandMessageMain } from '@/services/orchestration/main/handleCommandMessageMain';
import { OrchestrationHandlerMainService } from '@/services/orchestration/main/orchestration.handler.main.service';
import * as helpers from '@/services/orchestration/helpers';
import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee';
import { Logger } from '@/Logger';
import { Push } from '@/push';
import { ActiveWorkflowManager } from '@/ActiveWorkflowManager';
import { mockInstance } from '@test/mocking';
import { RedisClientService } from '@/services/redis/redis-client.service';
const instanceSettings = Container.get(InstanceSettings);
const redisClientService = mockInstance(RedisClientService);
const mockRedisClient = mock<Redis>();
redisClientService.createClient.mockReturnValue(mockRedisClient);
const os = Container.get(OrchestrationService);
const handler = Container.get(OrchestrationHandlerMainService);
mockInstance(ActiveWorkflowManager);
let queueModeId: string;
function setDefaultConfig() {
config.set('executions.mode', 'queue');
config.set('generic.instanceType', 'main');
}
const workerRestartEventBusResponse: RedisServiceWorkerResponseObject = {
senderId: 'test',
workerId: 'test',
command: 'restartEventBus',
payload: {
result: 'success',
},
};
describe('Orchestration Service', () => {
const logger = mockInstance(Logger);
mockInstance(Push);
mockInstance(RedisService);
mockInstance(ExternalSecretsManager);
const eventBus = mockInstance(MessageEventBus);
beforeAll(async () => {
jest.mock('@/services/redis/RedisServicePubSubPublisher', () => {
return jest.fn().mockImplementation(() => {
return {
init: jest.fn(),
publishToEventLog: jest.fn(),
publishToWorkerChannel: jest.fn(),
destroy: jest.fn(),
};
});
});
jest.mock('@/services/redis/RedisServicePubSubSubscriber', () => {
return jest.fn().mockImplementation(() => {
return {
subscribeToCommandChannel: jest.fn(),
destroy: jest.fn(),
};
});
});
setDefaultConfig();
queueModeId = config.get('redis.queueModeId');
});
beforeEach(() => {
instanceSettings.markAsLeader();
});
afterAll(async () => {
jest.mock('@/services/redis/RedisServicePubSubPublisher').restoreAllMocks();
jest.mock('@/services/redis/RedisServicePubSubSubscriber').restoreAllMocks();
await os.shutdown();
});
test('should initialize', async () => {
await os.init();
await handler.init();
expect(os.redisPublisher).toBeDefined();
expect(handler.redisSubscriber).toBeDefined();
expect(queueModeId).toBeDefined();
});
test('should handle worker responses', async () => {
const response = await handleWorkerResponseMessageMain(
JSON.stringify(workerRestartEventBusResponse),
);
expect(response.command).toEqual('restartEventBus');
});
test('should handle command messages from others', async () => {
const responseFalseId = await handleCommandMessageMain(
JSON.stringify({
senderId: 'test',
command: 'reloadLicense',
}),
);
expect(responseFalseId).toBeDefined();
expect(responseFalseId!.command).toEqual('reloadLicense');
expect(responseFalseId!.senderId).toEqual('test');
expect(logger.error).toHaveBeenCalled();
});
test('should reject command messages from itself', async () => {
const response = await handleCommandMessageMain(
JSON.stringify({ ...workerRestartEventBusResponse, senderId: queueModeId }),
);
expect(response).toBeDefined();
expect(response!.command).toEqual('restartEventBus');
expect(response!.senderId).toEqual(queueModeId);
expect(eventBus.restart).not.toHaveBeenCalled();
});
test('should send command messages', async () => {
setDefaultConfig();
jest.spyOn(os.redisPublisher, 'publishToCommandChannel').mockImplementation(async () => {});
await os.getWorkerIds();
expect(os.redisPublisher.publishToCommandChannel).toHaveBeenCalled();
jest.spyOn(os.redisPublisher, 'publishToCommandChannel').mockRestore();
});
test('should prevent receiving commands too often', async () => {
setDefaultConfig();
jest.spyOn(helpers, 'debounceMessageReceiver');
const res1 = await handleCommandMessageMain(
JSON.stringify({
senderId: 'test',
command: 'reloadExternalSecretsProviders',
}),
);
const res2 = await handleCommandMessageMain(
JSON.stringify({
senderId: 'test',
command: 'reloadExternalSecretsProviders',
}),
);
expect(helpers.debounceMessageReceiver).toHaveBeenCalledTimes(2);
expect(res1!.payload).toBeUndefined();
expect(res2!.payload).toEqual({ result: 'debounced' });
});
describe('shouldAddWebhooks', () => {
test('should return true for init', () => {
// We want to ensure that webhooks are populated on init
// more https://github.com/n8n-io/n8n/pull/8830
const result = os.shouldAddWebhooks('init');
expect(result).toBe(true);
});
test('should return false for leadershipChange', () => {
const result = os.shouldAddWebhooks('leadershipChange');
expect(result).toBe(false);
});
test('should return true for update or activate when is leader', () => {
const modes = ['update', 'activate'] as WorkflowActivateMode[];
for (const mode of modes) {
const result = os.shouldAddWebhooks(mode);
expect(result).toBe(true);
}
});
test('should return false for update or activate when not leader', () => {
instanceSettings.markAsFollower();
const modes = ['update', 'activate'] as WorkflowActivateMode[];
for (const mode of modes) {
const result = os.shouldAddWebhooks(mode);
expect(result).toBe(false);
}
});
});
});

View File

@@ -0,0 +1,195 @@
import { OwnershipService } from '@/services/ownership.service';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { User } from '@db/entities/User';
import type { SharedCredentials } from '@db/entities/SharedCredentials';
import { mockInstance } from '@test/mocking';
import { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
import { UserRepository } from '@/databases/repositories/user.repository';
import { mock } from 'jest-mock-extended';
import { Project } from '@/databases/entities/Project';
import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository';
import { ProjectRelation } from '@/databases/entities/ProjectRelation';
import { mockCredential, mockProject } from '@test/mockObjects';
describe('OwnershipService', () => {
const userRepository = mockInstance(UserRepository);
const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository);
const projectRelationRepository = mockInstance(ProjectRelationRepository);
const ownershipService = new OwnershipService(
mock(),
userRepository,
mock(),
projectRelationRepository,
sharedWorkflowRepository,
);
beforeEach(() => {
jest.clearAllMocks();
});
describe('getWorkflowProjectCached()', () => {
test('should retrieve a workflow owner project', async () => {
const mockProject = new Project();
const sharedWorkflow = Object.assign(new SharedWorkflow(), {
role: 'workflow:owner',
project: mockProject,
});
sharedWorkflowRepository.findOneOrFail.mockResolvedValueOnce(sharedWorkflow);
const returnedProject = await ownershipService.getWorkflowProjectCached('some-workflow-id');
expect(returnedProject).toBe(mockProject);
});
test('should throw if no workflow owner project found', async () => {
sharedWorkflowRepository.findOneOrFail.mockRejectedValue(new Error());
await expect(ownershipService.getWorkflowProjectCached('some-workflow-id')).rejects.toThrow();
});
});
describe('getProjectOwnerCached()', () => {
test('should retrieve a project owner', async () => {
const mockProject = new Project();
const mockOwner = new User();
const projectRelation = Object.assign(new ProjectRelation(), {
role: 'project:personalOwner',
project: mockProject,
user: mockOwner,
});
projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]);
const returnedOwner = await ownershipService.getProjectOwnerCached('some-project-id');
expect(returnedOwner).toBe(mockOwner);
});
test('should not throw if no project owner found, should return null instead', async () => {
projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([]);
const owner = await ownershipService.getProjectOwnerCached('some-project-id');
expect(owner).toBeNull();
});
});
describe('getProjectOwnerCached()', () => {
test('should retrieve a project owner', async () => {
const mockProject = new Project();
const mockOwner = new User();
const projectRelation = Object.assign(new ProjectRelation(), {
role: 'project:personalOwner',
project: mockProject,
user: mockOwner,
});
projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([projectRelation]);
const returnedOwner = await ownershipService.getProjectOwnerCached('some-project-id');
expect(returnedOwner).toBe(mockOwner);
});
test('should not throw if no project owner found, should return null instead', async () => {
projectRelationRepository.getPersonalProjectOwners.mockResolvedValueOnce([]);
const owner = await ownershipService.getProjectOwnerCached('some-project-id');
expect(owner).toBeNull();
});
});
describe('addOwnedByAndSharedWith()', () => {
test('should add `ownedBy` and `sharedWith` to credential', async () => {
const ownerProject = mockProject();
const editorProject = mockProject();
const credential = mockCredential();
credential.shared = [
{ role: 'credential:owner', project: ownerProject },
{ role: 'credential:editor', project: editorProject },
] as SharedCredentials[];
const { homeProject, sharedWithProjects } =
ownershipService.addOwnedByAndSharedWith(credential);
expect(homeProject).toMatchObject({
id: ownerProject.id,
name: ownerProject.name,
type: ownerProject.type,
});
expect(sharedWithProjects).toMatchObject([
{
id: editorProject.id,
name: editorProject.name,
type: editorProject.type,
},
]);
});
test('should add `ownedBy` and `sharedWith` to workflow', async () => {
const projectOwner = mockProject();
const projectEditor = mockProject();
const workflow = new WorkflowEntity();
workflow.shared = [
{ role: 'workflow:owner', project: projectOwner },
{ role: 'workflow:editor', project: projectEditor },
] as SharedWorkflow[];
const { homeProject, sharedWithProjects } =
ownershipService.addOwnedByAndSharedWith(workflow);
expect(homeProject).toMatchObject({
id: projectOwner.id,
name: projectOwner.name,
type: projectOwner.type,
});
expect(sharedWithProjects).toMatchObject([
{
id: projectEditor.id,
name: projectEditor.name,
type: projectEditor.type,
},
]);
});
test('should produce an empty sharedWith if no sharee', async () => {
const credential = mockCredential();
const project = mockProject();
credential.shared = [{ role: 'credential:owner', project }] as SharedCredentials[];
const { homeProject, sharedWithProjects } =
ownershipService.addOwnedByAndSharedWith(credential);
expect(homeProject).toMatchObject({
id: project.id,
name: project.name,
type: project.type,
});
expect(sharedWithProjects).toHaveLength(0);
});
});
describe('getInstanceOwner()', () => {
test('should find owner using global owner role ID', async () => {
await ownershipService.getInstanceOwner();
expect(userRepository.findOneOrFail).toHaveBeenCalledWith({
where: { role: 'global:owner' },
});
});
});
});

View File

@@ -0,0 +1,104 @@
import { PasswordUtility } from '@/services/password.utility';
import Container from 'typedi';
function toComponents(hash: string) {
const BCRYPT_HASH_REGEX =
/^\$(?<version>.{2})\$(?<costFactor>\d{2})\$(?<salt>.{22})(?<hashedPassword>.{31})$/;
const match = hash.match(BCRYPT_HASH_REGEX);
if (!match?.groups) throw new Error('Invalid bcrypt hash format');
return match.groups;
}
describe('PasswordUtility', () => {
const passwordUtility = Container.get(PasswordUtility);
describe('hash()', () => {
test('should hash a plaintext password', async () => {
const plaintext = 'abcd1234X';
const hashed = await passwordUtility.hash(plaintext);
const { version, costFactor, salt, hashedPassword } = toComponents(hashed);
expect(version).toBe('2a');
expect(costFactor).toBe('10');
expect(salt).toHaveLength(22);
expect(hashedPassword).toHaveLength(31);
});
});
describe('compare()', () => {
test('should return true on match', async () => {
const plaintext = 'abcd1234X';
const hashed = await passwordUtility.hash(plaintext);
const isMatch = await passwordUtility.compare(plaintext, hashed);
expect(isMatch).toBe(true);
});
test('should return false on mismatch', async () => {
const secondPlaintext = 'abcd1234Y';
const hashed = await passwordUtility.hash('abcd1234X');
const isMatch = await passwordUtility.compare(secondPlaintext, hashed);
expect(isMatch).toBe(false);
});
});
describe('validate()', () => {
test('should throw on empty password', () => {
const check = () => passwordUtility.validate();
expect(check).toThrowError('Password is mandatory');
});
test('should return same password if valid', () => {
const validPassword = 'abcd1234X';
const validated = passwordUtility.validate(validPassword);
expect(validated).toBe(validPassword);
});
test('should require at least one uppercase letter', () => {
const invalidPassword = 'abcd1234';
const failingCheck = () => passwordUtility.validate(invalidPassword);
expect(failingCheck).toThrowError('Password must contain at least 1 uppercase letter.');
});
test('should require at least one number', () => {
const validPassword = 'abcd1234X';
const invalidPassword = 'abcdEFGH';
const validated = passwordUtility.validate(validPassword);
expect(validated).toBe(validPassword);
const check = () => passwordUtility.validate(invalidPassword);
expect(check).toThrowError('Password must contain at least 1 number.');
});
test('should require a minimum length of 8 characters', () => {
const invalidPassword = 'a'.repeat(7);
const check = () => passwordUtility.validate(invalidPassword);
expect(check).toThrowError('Password must be 8 to 64 characters long.');
});
test('should require a maximum length of 64 characters', () => {
const invalidPassword = 'a'.repeat(65);
const check = () => passwordUtility.validate(invalidPassword);
expect(check).toThrowError('Password must be 8 to 64 characters long.');
});
});
});

View File

@@ -0,0 +1,56 @@
import Container from 'typedi';
import { Logger } from '@/Logger';
import config from '@/config';
import { RedisService } from '@/services/redis.service';
import { mockInstance } from '@test/mocking';
jest.mock('ioredis', () => {
const Redis = require('ioredis-mock');
if (typeof Redis === 'object') {
// the first mock is an ioredis shim because ioredis-mock depends on it
// https://github.com/stipsan/ioredis-mock/blob/master/src/index.js#L101-L111
return {
Command: { _transformer: { argument: {}, reply: {} } },
};
}
// second mock for our code
return function (...args: unknown[]) {
return new Redis(args);
};
});
mockInstance(Logger);
const redisService = Container.get(RedisService);
function setDefaultConfig() {
config.set('executions.mode', 'queue');
}
const PUBSUB_CHANNEL = 'testchannel';
describe('RedisService', () => {
beforeAll(async () => {
setDefaultConfig();
});
test('should create pubsub publisher and subscriber with handler', async () => {
const pub = await redisService.getPubSubPublisher();
const sub = await redisService.getPubSubSubscriber();
expect(pub).toBeDefined();
expect(sub).toBeDefined();
const mockHandler = jest.fn();
mockHandler.mockImplementation((_channel: string, _message: string) => {});
sub.addMessageHandler(PUBSUB_CHANNEL, mockHandler);
await sub.subscribe(PUBSUB_CHANNEL);
await pub.publish(PUBSUB_CHANNEL, 'test');
await new Promise((resolve) =>
setTimeout(async () => {
resolve(0);
}, 50),
);
expect(mockHandler).toHaveBeenCalled();
await sub.destroy();
await pub.destroy();
});
});

View File

@@ -0,0 +1,98 @@
import type { CacheService } from '@/services/cache/cache.service';
import type { OrchestrationService } from '@/services/orchestration.service';
import type { TestWebhookRegistration } from '@/services/test-webhook-registrations.service';
import { TestWebhookRegistrationsService } from '@/services/test-webhook-registrations.service';
import { mock } from 'jest-mock-extended';
describe('TestWebhookRegistrationsService', () => {
const cacheService = mock<CacheService>();
const registrations = new TestWebhookRegistrationsService(
cacheService,
mock<OrchestrationService>({ isMultiMainSetupEnabled: false }),
);
const registration = mock<TestWebhookRegistration>({
webhook: { httpMethod: 'GET', path: 'hello', webhookId: undefined },
});
const webhookKey = 'GET|hello';
const cacheKey = 'test-webhooks';
describe('register()', () => {
test('should register a test webhook registration', async () => {
await registrations.register(registration);
expect(cacheService.setHash).toHaveBeenCalledWith(cacheKey, { [webhookKey]: registration });
});
test('should skip setting TTL in single-main setup', async () => {
await registrations.register(registration);
expect(cacheService.expire).not.toHaveBeenCalled();
});
});
describe('deregister()', () => {
test('should deregister a test webhook registration', async () => {
await registrations.register(registration);
await registrations.deregister(webhookKey);
expect(cacheService.deleteFromHash).toHaveBeenCalledWith(cacheKey, webhookKey);
});
});
describe('get()', () => {
test('should retrieve a test webhook registration', async () => {
cacheService.getHashValue.mockResolvedValueOnce(registration);
const promise = registrations.get(webhookKey);
await expect(promise).resolves.toBe(registration);
});
test('should return undefined if no such test webhook registration was found', async () => {
cacheService.getHashValue.mockResolvedValueOnce(undefined);
const promise = registrations.get(webhookKey);
await expect(promise).resolves.toBeUndefined();
});
});
describe('getAllKeys()', () => {
test('should retrieve all test webhook registration keys', async () => {
cacheService.getHash.mockResolvedValueOnce({ [webhookKey]: registration });
const result = await registrations.getAllKeys();
expect(result).toEqual([webhookKey]);
});
});
describe('getAllRegistrations()', () => {
test('should retrieve all test webhook registrations', async () => {
cacheService.getHash.mockResolvedValueOnce({ [webhookKey]: registration });
const result = await registrations.getAllRegistrations();
expect(result).toEqual([registration]);
});
});
describe('deregisterAll()', () => {
test('should deregister all test webhook registrations', async () => {
await registrations.deregisterAll();
expect(cacheService.delete).toHaveBeenCalledWith(cacheKey);
});
});
describe('toKey()', () => {
test('should convert a test webhook registration to a key', () => {
const result = registrations.toKey(registration.webhook);
expect(result).toBe(webhookKey);
});
});
});

View File

@@ -0,0 +1,102 @@
import { mock } from 'jest-mock-extended';
import { v4 as uuid } from 'uuid';
import { User } from '@db/entities/User';
import { UserService } from '@/services/user.service';
import { UrlService } from '@/services/url.service';
import { mockInstance } from '@test/mocking';
import { UserRepository } from '@/databases/repositories/user.repository';
import { GlobalConfig } from '@n8n/config';
describe('UserService', () => {
const globalConfig = mockInstance(GlobalConfig, {
host: 'localhost',
path: '/',
port: 5678,
listen_address: '0.0.0.0',
protocol: 'http',
});
const urlService = new UrlService(globalConfig);
const userRepository = mockInstance(UserRepository);
const userService = new UserService(mock(), userRepository, mock(), urlService, mock());
const commonMockUser = Object.assign(new User(), {
id: uuid(),
password: 'passwordHash',
});
describe('toPublic', () => {
it('should remove sensitive properties', async () => {
const mockUser = Object.assign(new User(), {
id: uuid(),
password: 'passwordHash',
mfaEnabled: false,
mfaSecret: 'test',
mfaRecoveryCodes: ['test'],
updatedAt: new Date(),
authIdentities: [],
});
type MaybeSensitiveProperties = Partial<
Pick<User, 'password' | 'updatedAt' | 'authIdentities'>
>;
// to prevent typechecking from blocking assertions
const publicUser: MaybeSensitiveProperties = await userService.toPublic(mockUser);
expect(publicUser.password).toBeUndefined();
expect(publicUser.updatedAt).toBeUndefined();
expect(publicUser.authIdentities).toBeUndefined();
});
it('should add scopes if requested', async () => {
const scoped = await userService.toPublic(commonMockUser, { withScopes: true });
const unscoped = await userService.toPublic(commonMockUser);
expect(scoped.globalScopes).toEqual([]);
expect(unscoped.globalScopes).toBeUndefined();
});
it('should add invite URL if requested', async () => {
const firstUser = Object.assign(new User(), { id: uuid() });
const secondUser = Object.assign(new User(), { id: uuid(), isPending: true });
const withoutUrl = await userService.toPublic(secondUser);
const withUrl = await userService.toPublic(secondUser, {
withInviteUrl: true,
inviterId: firstUser.id,
});
expect(withoutUrl.inviteAcceptUrl).toBeUndefined();
const url = new URL(withUrl.inviteAcceptUrl ?? '');
expect(url.searchParams.get('inviterId')).toBe(firstUser.id);
expect(url.searchParams.get('inviteeId')).toBe(secondUser.id);
});
});
describe('update', () => {
// We need to use `save` so that that the subscriber in
// packages/cli/src/databases/entities/Project.ts receives the full user.
// With `update` it would only receive the updated fields, e.g. the `id`
// would be missing.
it('should use `save` instead of `update`', async () => {
const user = new User();
user.firstName = 'Not Nathan';
user.lastName = 'Nathaniel';
const userId = '1234';
const data = {
firstName: 'Nathan',
};
userRepository.findOneBy.mockResolvedValueOnce(user);
await userService.update(userId, data);
expect(userRepository.save).toHaveBeenCalledWith({ ...user, ...data }, { transaction: true });
expect(userRepository.update).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,190 @@
import { v4 as uuid } from 'uuid';
import config from '@/config';
import { WebhookRepository } from '@db/repositories/webhook.repository';
import { CacheService } from '@/services/cache/cache.service';
import { WebhookService } from '@/services/webhook.service';
import { WebhookEntity } from '@db/entities/WebhookEntity';
import { mockInstance } from '@test/mocking';
const createWebhook = (method: string, path: string, webhookId?: string, pathSegments?: number) =>
Object.assign(new WebhookEntity(), {
method,
webhookPath: path,
webhookId,
pathSegments,
}) as WebhookEntity;
describe('WebhookService', () => {
const webhookRepository = mockInstance(WebhookRepository);
const cacheService = mockInstance(CacheService);
const webhookService = new WebhookService(webhookRepository, cacheService);
beforeEach(() => {
config.load(config.default);
jest.clearAllMocks();
});
[true, false].forEach((isCacheEnabled) => {
const tag = '[' + ['cache', isCacheEnabled ? 'enabled' : 'disabled'].join(' ') + ']';
describe(`findWebhook() - static case ${tag}`, () => {
test('should return the webhook if found', async () => {
const method = 'GET';
const path = 'user/profile';
const mockWebhook = createWebhook(method, path);
webhookRepository.findOneBy.mockResolvedValue(mockWebhook);
const returnedWebhook = await webhookService.findWebhook(method, path);
expect(returnedWebhook).toBe(mockWebhook);
});
test('should return null if not found', async () => {
webhookRepository.findOneBy.mockResolvedValue(null); // static
webhookRepository.findBy.mockResolvedValue([]);
const returnValue = await webhookService.findWebhook('GET', 'user/profile');
expect(returnValue).toBeNull();
});
});
describe(`findWebhook() - dynamic case ${tag}`, () => {
test('should return the webhook if found', async () => {
const method = 'GET';
const webhookId = uuid();
const path = 'user/:id/posts';
const mockWebhook = createWebhook(method, path, webhookId, 3);
webhookRepository.findOneBy.mockResolvedValue(null); // static
webhookRepository.findBy.mockResolvedValue([mockWebhook]); // dynamic
const returnedWebhook = await webhookService.findWebhook(
method,
[webhookId, 'user/123/posts'].join('/'),
);
expect(returnedWebhook).toBe(mockWebhook);
});
test('should handle subset dynamic path case', async () => {
const method1 = 'GET';
const webhookId1 = uuid();
const path1 = 'user/:id/posts';
const mockWebhook1 = createWebhook(method1, path1, webhookId1, 3);
const method2 = 'GET';
const webhookId2 = uuid();
const path2 = 'user/:id/posts/:postId/comments';
const mockWebhook2 = createWebhook(method2, path2, webhookId2, 3);
webhookRepository.findOneBy.mockResolvedValue(null); // static
webhookRepository.findBy.mockResolvedValue([mockWebhook1, mockWebhook2]); // dynamic
const fullPath1 = [webhookId1, 'user/123/posts'].join('/');
const returnedWebhook1 = await webhookService.findWebhook(method1, fullPath1);
const fullPath2 = [webhookId1, 'user/123/posts/456/comments'].join('/');
const returnedWebhook2 = await webhookService.findWebhook(method2, fullPath2);
expect(returnedWebhook1).toBe(mockWebhook1);
expect(returnedWebhook2).toBe(mockWebhook2);
});
test('should handle single-segment dynamic path case', async () => {
const method1 = 'GET';
const webhookId1 = uuid();
const path1 = ':var';
const mockWebhook1 = createWebhook(method1, path1, webhookId1, 3);
const method2 = 'GET';
const webhookId2 = uuid();
const path2 = 'user/:id/posts/:postId/comments';
const mockWebhook2 = createWebhook(method2, path2, webhookId2, 3);
webhookRepository.findOneBy.mockResolvedValue(null); // static
webhookRepository.findBy.mockResolvedValue([mockWebhook1, mockWebhook2]); // dynamic
const fullPath = [webhookId1, 'user/123/posts/456'].join('/');
const returnedWebhook = await webhookService.findWebhook(method1, fullPath);
expect(returnedWebhook).toBe(mockWebhook1);
});
test('should return null if not found', async () => {
const fullPath = [uuid(), 'user/:id/posts'].join('/');
webhookRepository.findOneBy.mockResolvedValue(null); // static
webhookRepository.findBy.mockResolvedValue([]); // dynamic
const returnValue = await webhookService.findWebhook('GET', fullPath);
expect(returnValue).toBeNull();
});
});
});
describe('getWebhookMethods()', () => {
test('should return all methods for webhook', async () => {
const path = 'user/profile';
webhookRepository.find.mockResolvedValue([
createWebhook('GET', path),
createWebhook('POST', path),
createWebhook('PUT', path),
createWebhook('PATCH', path),
]);
const returnedMethods = await webhookService.getWebhookMethods(path);
expect(returnedMethods).toEqual(['GET', 'POST', 'PUT', 'PATCH']);
});
test('should return empty array if no webhooks found', async () => {
webhookRepository.find.mockResolvedValue([]);
const returnedMethods = await webhookService.getWebhookMethods('user/profile');
expect(returnedMethods).toEqual([]);
});
});
describe('deleteWorkflowWebhooks()', () => {
test('should delete all webhooks of the workflow', async () => {
const mockWorkflowWebhooks = [
createWebhook('PUT', 'users'),
createWebhook('GET', 'user/:id'),
createWebhook('POST', ':var'),
];
webhookRepository.findBy.mockResolvedValue(mockWorkflowWebhooks);
const workflowId = uuid();
await webhookService.deleteWorkflowWebhooks(workflowId);
expect(webhookRepository.remove).toHaveBeenCalledWith(mockWorkflowWebhooks);
});
test('should not delete any webhooks if none found', async () => {
webhookRepository.findBy.mockResolvedValue([]);
const workflowId = uuid();
await webhookService.deleteWorkflowWebhooks(workflowId);
expect(webhookRepository.remove).toHaveBeenCalledWith([]);
});
});
describe('createWebhook()', () => {
test('should create the webhook', async () => {
const mockWebhook = createWebhook('GET', 'user/:id');
await webhookService.storeWebhook(mockWebhook);
expect(webhookRepository.insert).toHaveBeenCalledWith(mockWebhook);
});
});
});

View File

@@ -0,0 +1,223 @@
import { Container } from 'typedi';
import { GlobalConfig } from '@n8n/config';
import type { IRun, WorkflowExecuteMode } from 'n8n-workflow';
import {
QueryFailedError,
type DataSource,
type EntityManager,
type EntityMetadata,
} from '@n8n/typeorm';
import { mocked } from 'jest-mock';
import { mock } from 'jest-mock-extended';
import config from '@/config';
import type { User } from '@db/entities/User';
import type { WorkflowStatistics } from '@db/entities/WorkflowStatistics';
import { WorkflowStatisticsRepository } from '@db/repositories/workflowStatistics.repository';
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
import { UserService } from '@/services/user.service';
import { OwnershipService } from '@/services/ownership.service';
import { mockInstance } from '@test/mocking';
import type { Project } from '@/databases/entities/Project';
describe('WorkflowStatisticsService', () => {
const fakeUser = mock<User>({ id: 'abcde-fghij' });
const fakeProject = mock<Project>({ id: '12345-67890', type: 'personal' });
const ownershipService = mockInstance(OwnershipService);
const userService = mockInstance(UserService);
const globalConfig = Container.get(GlobalConfig);
const dbType = globalConfig.database.type;
const entityManager = mock<EntityManager>();
const dataSource = mock<DataSource>({
manager: entityManager,
getMetadata: () =>
mock<EntityMetadata>({
tableName: 'workflow_statistics',
}),
});
Object.assign(entityManager, { connection: dataSource });
config.set('diagnostics.enabled', true);
config.set('deployment.type', 'n8n-testing');
mocked(ownershipService.getWorkflowProjectCached).mockResolvedValue(fakeProject);
mocked(ownershipService.getProjectOwnerCached).mockResolvedValue(fakeUser);
const updateSettingsMock = jest.spyOn(userService, 'updateSettings').mockImplementation();
const workflowStatisticsService = new WorkflowStatisticsService(
mock(),
new WorkflowStatisticsRepository(dataSource, globalConfig),
ownershipService,
userService,
);
const onFirstProductionWorkflowSuccess = jest.fn();
const onFirstWorkflowDataLoad = jest.fn();
workflowStatisticsService.on(
'telemetry.onFirstProductionWorkflowSuccess',
onFirstProductionWorkflowSuccess,
);
workflowStatisticsService.on('telemetry.onFirstWorkflowDataLoad', onFirstWorkflowDataLoad);
beforeEach(() => {
jest.clearAllMocks();
});
const mockDBCall = (count = 1) => {
if (dbType === 'sqlite') {
entityManager.findOne.mockResolvedValueOnce(mock<WorkflowStatistics>({ count }));
} else {
const result = dbType === 'postgresdb' ? [{ count }] : { affectedRows: count };
entityManager.query.mockImplementationOnce(async (query) =>
query.startsWith('INSERT INTO') ? result : null,
);
}
};
describe('workflowExecutionCompleted', () => {
test('should create metrics for production successes', async () => {
// Call the function with a production success result, ensure metrics hook gets called
const workflow = {
id: '1',
name: '',
active: false,
createdAt: new Date(),
updatedAt: new Date(),
nodes: [],
connections: {},
};
const runData: IRun = {
finished: true,
status: 'success',
data: { resultData: { runData: {} } },
mode: 'internal' as WorkflowExecuteMode,
startedAt: new Date(),
};
mockDBCall();
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
expect(updateSettingsMock).toHaveBeenCalledTimes(1);
expect(onFirstProductionWorkflowSuccess).toBeCalledTimes(1);
expect(onFirstProductionWorkflowSuccess).toHaveBeenNthCalledWith(1, {
project_id: fakeProject.id,
user_id: fakeUser.id,
workflow_id: workflow.id,
});
});
test('should only create metrics for production successes', async () => {
// Call the function with a non production success result, ensure metrics hook is never called
const workflow = {
id: '1',
name: '',
active: false,
createdAt: new Date(),
updatedAt: new Date(),
nodes: [],
connections: {},
};
const runData: IRun = {
finished: false,
status: 'error',
data: { resultData: { runData: {} } },
mode: 'internal' as WorkflowExecuteMode,
startedAt: new Date(),
};
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
expect(onFirstProductionWorkflowSuccess).toBeCalledTimes(0);
});
test('should not send metrics for updated entries', async () => {
// Call the function with a fail insert, ensure update is called *and* metrics aren't sent
const workflow = {
id: '1',
name: '',
active: false,
createdAt: new Date(),
updatedAt: new Date(),
nodes: [],
connections: {},
};
const runData: IRun = {
finished: true,
status: 'success',
data: { resultData: { runData: {} } },
mode: 'internal' as WorkflowExecuteMode,
startedAt: new Date(),
};
mockDBCall(2);
await workflowStatisticsService.workflowExecutionCompleted(workflow, runData);
expect(onFirstProductionWorkflowSuccess).toBeCalledTimes(0);
});
});
describe('nodeFetchedData', () => {
test('should create metrics when the db is updated', async () => {
// Call the function with a production success result, ensure metrics hook gets called
const workflowId = '1';
const node = {
id: 'abcde',
name: 'test node',
typeVersion: 1,
type: '',
position: [0, 0] as [number, number],
parameters: {},
};
await workflowStatisticsService.nodeFetchedData(workflowId, node);
expect(onFirstWorkflowDataLoad).toBeCalledTimes(1);
expect(onFirstWorkflowDataLoad).toHaveBeenNthCalledWith(1, {
user_id: fakeUser.id,
project_id: fakeProject.id,
workflow_id: workflowId,
node_type: node.type,
node_id: node.id,
});
});
test('should create metrics with credentials when the db is updated', async () => {
// Call the function with a production success result, ensure metrics hook gets called
const workflowId = '1';
const node = {
id: 'abcde',
name: 'test node',
typeVersion: 1,
type: '',
position: [0, 0] as [number, number],
parameters: {},
credentials: {
testCredentials: {
id: '1',
name: 'Test Credentials',
},
},
};
await workflowStatisticsService.nodeFetchedData(workflowId, node);
expect(onFirstWorkflowDataLoad).toBeCalledTimes(1);
expect(onFirstWorkflowDataLoad).toHaveBeenNthCalledWith(1, {
user_id: fakeUser.id,
project_id: fakeProject.id,
workflow_id: workflowId,
node_type: node.type,
node_id: node.id,
credential_type: 'testCredentials',
credential_id: node.credentials.testCredentials.id,
});
});
test('should not send metrics for entries that already have the flag set', async () => {
// Fetch data for workflow 2 which is set up to not be altered in the mocks
entityManager.insert.mockRejectedValueOnce(new QueryFailedError('', undefined, new Error()));
const workflowId = '1';
const node = {
id: 'abcde',
name: 'test node',
typeVersion: 1,
type: '',
position: [0, 0] as [number, number],
parameters: {},
};
await workflowStatisticsService.nodeFetchedData(workflowId, node);
expect(onFirstWorkflowDataLoad).toBeCalledTimes(0);
});
});
});

View File

@@ -0,0 +1,67 @@
import Container from 'typedi';
import { mock } from 'jest-mock-extended';
import { CacheService } from '@/services/cache/cache.service';
const cacheService = Container.get(CacheService);
const store = mock<NonNullable<CacheService['cache']>['store']>({ isCacheable: () => true });
Object.assign(cacheService, { cache: { store } });
describe('CacheService (Mock)', () => {
beforeEach(() => jest.clearAllMocks());
describe('should prevent use of empty keys', () => {
test('get', async () => {
await cacheService.get('');
expect(store.get).not.toHaveBeenCalled();
await cacheService.get('key');
expect(store.get).toHaveBeenCalledWith('key');
});
test('getMany', async () => {
await cacheService.getMany([]);
expect(store.mget).not.toHaveBeenCalled();
await cacheService.getMany(['key1', 'key2']);
expect(store.mget).toHaveBeenCalledWith('key1', 'key2');
});
test('set', async () => {
await cacheService.set('', '');
expect(store.set).not.toHaveBeenCalled();
await cacheService.set('key', 'value');
expect(store.set).toHaveBeenCalledWith('key', 'value', undefined);
await cacheService.set('key', 'value', 123);
expect(store.set).toHaveBeenCalledWith('key', 'value', 123);
});
test('setMany', async () => {
await cacheService.setMany([]);
expect(store.mset).not.toHaveBeenCalled();
await cacheService.setMany([['key', 'value']]);
expect(store.mset).toHaveBeenCalledWith([['key', 'value']], undefined);
await cacheService.setMany([['key', 'value']], 123);
expect(store.mset).toHaveBeenCalledWith([['key', 'value']], 123);
});
test('delete', async () => {
await cacheService.delete('');
expect(store.del).not.toHaveBeenCalled();
await cacheService.delete('key');
expect(store.del).toHaveBeenCalledWith('key');
});
test('deleteMany', async () => {
await cacheService.deleteMany([]);
expect(store.mdel).not.toHaveBeenCalled();
await cacheService.deleteMany(['key1', 'key2']);
expect(store.mdel).toHaveBeenCalledWith('key1', 'key2');
});
});
});

View File

@@ -0,0 +1,245 @@
import { CacheService } from '@/services/cache/cache.service';
import config from '@/config';
import { sleep } from 'n8n-workflow';
import { GlobalConfig } from '@n8n/config';
import Container from 'typedi';
jest.mock('ioredis', () => {
const Redis = require('ioredis-mock');
return function (...args: unknown[]) {
return new Redis(args);
};
});
for (const backend of ['memory', 'redis'] as const) {
describe(backend, () => {
let cacheService: CacheService;
let globalConfig: GlobalConfig;
beforeAll(async () => {
globalConfig = Container.get(GlobalConfig);
globalConfig.cache.backend = backend;
cacheService = new CacheService(globalConfig);
await cacheService.init();
});
afterEach(async () => {
await cacheService.reset();
config.load(config.default);
});
describe('init', () => {
test('should select backend based on config', () => {
expect(cacheService.isMemory()).toBe(backend === 'memory');
expect(cacheService.isRedis()).toBe(backend === 'redis');
});
if (backend === 'redis') {
test('with auto backend and queue mode, should select redis', async () => {
config.set('executions.mode', 'queue');
await cacheService.init();
expect(cacheService.isRedis()).toBe(true);
});
}
if (backend === 'memory') {
test('should honor max size when enough', async () => {
globalConfig.cache.memory.maxSize = 16; // enough bytes for "withoutUnicode"
await cacheService.init();
await cacheService.set('key', 'withoutUnicode');
await expect(cacheService.get('key')).resolves.toBe('withoutUnicode');
// restore
globalConfig.cache.memory.maxSize = 3 * 1024 * 1024;
await cacheService.init();
});
test('should honor max size when not enough', async () => {
globalConfig.cache.memory.maxSize = 16; // not enough bytes for "withUnicodeԱԲԳ"
await cacheService.init();
await cacheService.set('key', 'withUnicodeԱԲԳ');
await expect(cacheService.get('key')).resolves.toBeUndefined();
// restore
globalConfig.cache.memory.maxSize = 3 * 1024 * 1024;
// restore
await cacheService.init();
});
}
});
describe('set', () => {
test('should set a string value', async () => {
await cacheService.set('key', 'value');
await expect(cacheService.get('key')).resolves.toBe('value');
});
test('should set a number value', async () => {
await cacheService.set('key', 123);
await expect(cacheService.get('key')).resolves.toBe(123);
});
test('should set an object value', async () => {
const object = { a: { b: { c: { d: 1 } } } };
await cacheService.set('key', object);
await expect(cacheService.get('key')).resolves.toMatchObject(object);
});
test('should not cache `null` or `undefined` values', async () => {
await cacheService.set('key1', null);
await cacheService.set('key2', undefined);
await cacheService.set('key3', 'value');
await expect(cacheService.get('key1')).resolves.toBeUndefined();
await expect(cacheService.get('key2')).resolves.toBeUndefined();
await expect(cacheService.get('key3')).resolves.toBe('value');
});
test('should disregard zero-length keys', async () => {
await cacheService.set('', 'value');
await expect(cacheService.get('')).resolves.toBeUndefined();
});
test('should honor ttl', async () => {
await cacheService.set('key', 'value', 100);
await expect(cacheService.get('key')).resolves.toBe('value');
await sleep(200);
await expect(cacheService.get('key')).resolves.toBeUndefined();
});
});
describe('get', () => {
test('should fall back to fallback value', async () => {
const promise = cacheService.get('key', { fallbackValue: 'fallback' });
await expect(promise).resolves.toBe('fallback');
});
test('should refresh value', async () => {
const promise = cacheService.get('testString', {
refreshFn: async () => 'refreshValue',
});
await expect(promise).resolves.toBe('refreshValue');
});
test('should handle non-ASCII key', async () => {
const nonAsciiKey = 'ԱԲԳ';
await cacheService.set(nonAsciiKey, 'value');
await expect(cacheService.get(nonAsciiKey)).resolves.toBe('value');
});
});
describe('delete', () => {
test('should delete a key', async () => {
await cacheService.set('key', 'value');
await cacheService.delete('key');
await expect(cacheService.get('key')).resolves.toBeUndefined();
});
});
describe('setMany', () => {
test('should set multiple string values', async () => {
await cacheService.setMany([
['key1', 'value1'],
['key2', 'value2'],
]);
const promise = cacheService.getMany(['key1', 'key2']);
await expect(promise).resolves.toStrictEqual(['value1', 'value2']);
});
test('should set multiple number values', async () => {
await cacheService.setMany([
['key1', 123],
['key2', 456],
]);
const promise = cacheService.getMany(['key1', 'key2']);
await expect(promise).resolves.toStrictEqual([123, 456]);
});
test('should disregard zero-length keys', async () => {
await cacheService.setMany([['', 'value1']]);
await expect(cacheService.get('')).resolves.toBeUndefined();
});
});
describe('getMany', () => {
test('should return undefined on missing result', async () => {
await cacheService.setMany([
['key1', 123],
['key2', 456],
]);
const promise = cacheService.getMany(['key2', 'key3']);
await expect(promise).resolves.toStrictEqual([456, undefined]);
});
});
describe('delete', () => {
test('should handle non-ASCII key', async () => {
const nonAsciiKey = 'ԱԲԳ';
await cacheService.set(nonAsciiKey, 'value');
await expect(cacheService.get(nonAsciiKey)).resolves.toBe('value');
await cacheService.delete(nonAsciiKey);
await expect(cacheService.get(nonAsciiKey)).resolves.toBeUndefined();
});
});
describe('setHash', () => {
test('should set a hash if non-existing', async () => {
await cacheService.setHash('keyW', { field: 'value' });
await expect(cacheService.getHash('keyW')).resolves.toStrictEqual({ field: 'value' });
});
test('should add to a hash value if existing', async () => {
await cacheService.setHash('key', { field1: 'value1' });
await cacheService.setHash('key', { field2: 'value2' });
await expect(cacheService.getHash('key')).resolves.toStrictEqual({
field1: 'value1',
field2: 'value2',
});
});
});
describe('deleteFromHash', () => {
test('should delete a hash field', async () => {
await cacheService.setHash('key', { field1: 'value1', field2: 'value2' });
await cacheService.deleteFromHash('key', 'field1');
await expect(cacheService.getHash('key')).resolves.toStrictEqual({ field2: 'value2' });
});
});
describe('getHashValue', () => {
test('should return a hash field value', async () => {
await cacheService.setHash('key', { field1: 'value1', field2: 'value2' });
await expect(cacheService.getHashValue('key', 'field1')).resolves.toBe('value1');
});
});
});
}