test(core): Move unit tests closer to testable components (no-changelog) (#10287)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
297
packages/cli/src/services/__tests__/curl.service.test.ts
Normal file
297
packages/cli/src/services/__tests__/curl.service.test.ts
Normal 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¶m2=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);
|
||||
});
|
||||
});
|
||||
164
packages/cli/src/services/__tests__/hooks.service.test.ts
Normal file
164
packages/cli/src/services/__tests__/hooks.service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
62
packages/cli/src/services/__tests__/jwt.service.test.ts
Normal file
62
packages/cli/src/services/__tests__/jwt.service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
87
packages/cli/src/services/__tests__/naming.service.test.ts
Normal file
87
packages/cli/src/services/__tests__/naming.service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
195
packages/cli/src/services/__tests__/ownership.service.test.ts
Normal file
195
packages/cli/src/services/__tests__/ownership.service.test.ts
Normal 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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
104
packages/cli/src/services/__tests__/password.utility.test.ts
Normal file
104
packages/cli/src/services/__tests__/password.utility.test.ts
Normal 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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
56
packages/cli/src/services/__tests__/redis.service.test.ts
Normal file
56
packages/cli/src/services/__tests__/redis.service.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
102
packages/cli/src/services/__tests__/user.service.test.ts
Normal file
102
packages/cli/src/services/__tests__/user.service.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
190
packages/cli/src/services/__tests__/webhook.service.test.ts
Normal file
190
packages/cli/src/services/__tests__/webhook.service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
67
packages/cli/src/services/cache/__tests__/cache-mock.service.test.ts
vendored
Normal file
67
packages/cli/src/services/cache/__tests__/cache-mock.service.test.ts
vendored
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
245
packages/cli/src/services/cache/__tests__/cache.service.test.ts
vendored
Normal file
245
packages/cli/src/services/cache/__tests__/cache.service.test.ts
vendored
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user