first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import {
|
||||
testDb,
|
||||
createWorkflow,
|
||||
createWorkflowHistory,
|
||||
setActiveVersion,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import type { IWorkflowDb } from '@n8n/db';
|
||||
import { WorkflowDependencyRepository, WorkflowRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { retryUntil } from '@test-integration/retry-until';
|
||||
import { ErrorReporter, Tracing } from 'n8n-core';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { createOwner } from '../shared/db/users';
|
||||
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
|
||||
|
||||
let workflowIndexService: WorkflowIndexService;
|
||||
let eventService: EventService;
|
||||
let workflowRepository: WorkflowRepository;
|
||||
let workflowDependencyRepository: WorkflowDependencyRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
|
||||
// Get real instances from the container
|
||||
workflowRepository = Container.get(WorkflowRepository);
|
||||
workflowDependencyRepository = Container.get(WorkflowDependencyRepository);
|
||||
eventService = Container.get(EventService);
|
||||
|
||||
// Create the WorkflowIndexService with real dependencies
|
||||
workflowIndexService = new WorkflowIndexService(
|
||||
workflowDependencyRepository,
|
||||
workflowRepository,
|
||||
eventService,
|
||||
Container.get(Logger),
|
||||
Container.get(ErrorReporter),
|
||||
Container.get(Tracing),
|
||||
);
|
||||
|
||||
// Initialize the service to register event listeners
|
||||
workflowIndexService.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await testDb.truncate(['WorkflowEntity', 'WorkflowDependency', 'WorkflowHistory']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('WorkflowIndexService Integration', () => {
|
||||
const createUserPayload = (owner: Awaited<ReturnType<typeof createOwner>>) => ({
|
||||
id: owner.id,
|
||||
email: owner.email,
|
||||
firstName: owner.firstName,
|
||||
lastName: owner.lastName,
|
||||
role: { slug: owner.role.slug },
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a workflow with draft content, indexes it, then creates and indexes
|
||||
* a published version with different content.
|
||||
* Returns the workflow (with published nodes) and the published version ID.
|
||||
*/
|
||||
async function createAndIndexDraftAndPublishedWorkflow(
|
||||
owner: Awaited<ReturnType<typeof createOwner>>,
|
||||
) {
|
||||
const draftWorkflow = await createWorkflow({
|
||||
name: 'Workflow with Draft and Published',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node-1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Index the draft version
|
||||
eventService.emit('workflow-created', {
|
||||
user: createUserPayload(owner),
|
||||
workflow: draftWorkflow,
|
||||
publicApi: false,
|
||||
projectId: uuid(),
|
||||
projectType: 'personal',
|
||||
});
|
||||
|
||||
await retryUntil(async () => {
|
||||
const deps = await workflowDependencyRepository.find({
|
||||
where: { workflowId: draftWorkflow.id },
|
||||
});
|
||||
expect(deps).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Create and activate a published version with different content
|
||||
const publishedVersionId = uuid();
|
||||
const publishedNodes = [
|
||||
{
|
||||
id: 'node-2',
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {},
|
||||
},
|
||||
];
|
||||
|
||||
draftWorkflow.active = true;
|
||||
draftWorkflow.versionCounter = 2;
|
||||
draftWorkflow.nodes = publishedNodes;
|
||||
const savedWorkflow = await workflowRepository.save(draftWorkflow);
|
||||
|
||||
await createWorkflowHistory({
|
||||
...savedWorkflow,
|
||||
versionId: publishedVersionId,
|
||||
nodes: publishedNodes,
|
||||
});
|
||||
await setActiveVersion(savedWorkflow.id, publishedVersionId);
|
||||
savedWorkflow.activeVersionId = publishedVersionId;
|
||||
|
||||
// Index the published version
|
||||
eventService.emit('workflow-activated', {
|
||||
user: createUserPayload(owner),
|
||||
workflow: savedWorkflow,
|
||||
workflowId: savedWorkflow.id,
|
||||
publicApi: false,
|
||||
});
|
||||
|
||||
// Wait for both draft and published entries to be indexed with their expected content
|
||||
await retryUntil(async () => {
|
||||
const deps = await workflowDependencyRepository.find({
|
||||
where: { workflowId: savedWorkflow.id },
|
||||
});
|
||||
expect(deps).toHaveLength(2);
|
||||
|
||||
const draftDep = deps.find((d) => d.publishedVersionId === null);
|
||||
const publishedDep = deps.find((d) => d.publishedVersionId === publishedVersionId);
|
||||
expect(draftDep).toBeDefined();
|
||||
expect(publishedDep).toBeDefined();
|
||||
});
|
||||
|
||||
return { workflow: savedWorkflow, publishedVersionId };
|
||||
}
|
||||
|
||||
describe('workflow-created event', () => {
|
||||
it('should index a new workflow with a single node', async () => {
|
||||
const owner = await createOwner();
|
||||
const workflowId = uuid();
|
||||
const versionId = uuid();
|
||||
|
||||
const workflow = {
|
||||
id: workflowId,
|
||||
name: 'Test Workflow',
|
||||
active: false,
|
||||
activeVersionId: null,
|
||||
versionCounter: 1,
|
||||
versionId,
|
||||
nodes: [
|
||||
{
|
||||
id: 'node-1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
settings: {},
|
||||
triggerCount: 0,
|
||||
isArchived: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} satisfies IWorkflowDb;
|
||||
|
||||
const savedWorkflow = await workflowRepository.save(workflow);
|
||||
|
||||
eventService.emit('workflow-created', {
|
||||
user: createUserPayload(owner),
|
||||
workflow: savedWorkflow,
|
||||
publicApi: false,
|
||||
projectId: uuid(),
|
||||
projectType: 'personal',
|
||||
});
|
||||
|
||||
await retryUntil(async () => {
|
||||
const dependencies = await workflowDependencyRepository.find({
|
||||
where: { workflowId },
|
||||
});
|
||||
|
||||
expect(dependencies).toHaveLength(1);
|
||||
expect(dependencies[0]).toMatchObject({
|
||||
workflowId,
|
||||
workflowVersionId: 1,
|
||||
dependencyType: 'nodeType',
|
||||
dependencyKey: 'n8n-nodes-base.httpRequest',
|
||||
dependencyInfo: {
|
||||
nodeId: 'node-1',
|
||||
nodeVersion: 1,
|
||||
},
|
||||
indexVersionId: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflow-activated event (published version indexing)', () => {
|
||||
it('should keep draft and published dependencies separate', async () => {
|
||||
const owner = await createOwner();
|
||||
const { workflow, publishedVersionId } = await createAndIndexDraftAndPublishedWorkflow(owner);
|
||||
|
||||
await retryUntil(async () => {
|
||||
const allDependencies = await workflowDependencyRepository.find({
|
||||
where: { workflowId: workflow.id },
|
||||
order: { publishedVersionId: 'ASC' },
|
||||
});
|
||||
|
||||
expect(allDependencies).toHaveLength(2);
|
||||
|
||||
const draftDep = allDependencies.find((d) => d.publishedVersionId === null);
|
||||
expect(draftDep).toMatchObject({
|
||||
workflowId: workflow.id,
|
||||
publishedVersionId: null,
|
||||
dependencyType: 'nodeType',
|
||||
dependencyKey: 'n8n-nodes-base.httpRequest',
|
||||
dependencyInfo: {
|
||||
nodeId: 'node-1',
|
||||
nodeVersion: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const publishedDep = allDependencies.find((d) => d.publishedVersionId !== null);
|
||||
expect(publishedDep).toMatchObject({
|
||||
workflowId: workflow.id,
|
||||
publishedVersionId,
|
||||
dependencyType: 'nodeType',
|
||||
dependencyKey: 'n8n-nodes-base.slack',
|
||||
dependencyInfo: {
|
||||
nodeId: 'node-2',
|
||||
nodeVersion: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflow-deleted event', () => {
|
||||
it('should remove both draft and published index entries', async () => {
|
||||
const owner = await createOwner();
|
||||
const { workflow } = await createAndIndexDraftAndPublishedWorkflow(owner);
|
||||
|
||||
// Delete the workflow
|
||||
eventService.emit('workflow-deleted', {
|
||||
user: createUserPayload(owner),
|
||||
workflowId: workflow.id,
|
||||
publicApi: false,
|
||||
});
|
||||
|
||||
// Verify all entries are removed
|
||||
await retryUntil(async () => {
|
||||
const remainingDeps = await workflowDependencyRepository.find({
|
||||
where: { workflowId: workflow.id },
|
||||
});
|
||||
expect(remainingDeps).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { LicenseState } from '@n8n/backend-common';
|
||||
import { createWorkflow, shareWorkflowWithUsers, testDb } from '@n8n/backend-test-utils';
|
||||
import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, type User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
|
||||
|
||||
import { createUser } from '../shared/db/users';
|
||||
|
||||
let owner: User;
|
||||
let member: User;
|
||||
let anotherMember: User;
|
||||
let workflowSharingService: WorkflowSharingService;
|
||||
let projectService: ProjectService;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
owner = await createUser({ role: GLOBAL_OWNER_ROLE });
|
||||
member = await createUser({ role: GLOBAL_MEMBER_ROLE });
|
||||
anotherMember = await createUser({ role: GLOBAL_MEMBER_ROLE });
|
||||
const licenseMock = mock<LicenseState>();
|
||||
licenseMock.isSharingLicensed.mockReturnValue(true);
|
||||
licenseMock.getMaxTeamProjects.mockReturnValue(-1);
|
||||
Container.set(LicenseState, licenseMock);
|
||||
workflowSharingService = Container.get(WorkflowSharingService);
|
||||
projectService = Container.get(ProjectService);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['WorkflowEntity', 'SharedWorkflow', 'WorkflowHistory']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('WorkflowSharingService', () => {
|
||||
describe('getSharedWorkflowIds', () => {
|
||||
it('should show all workflows to owners', async () => {
|
||||
const workflow1 = await createWorkflow({}, member);
|
||||
const workflow2 = await createWorkflow({}, anotherMember);
|
||||
const sharedWorkflowIds = await workflowSharingService.getSharedWorkflowIds(owner, {
|
||||
scopes: ['workflow:read'],
|
||||
});
|
||||
expect(sharedWorkflowIds).toHaveLength(2);
|
||||
expect(sharedWorkflowIds).toContain(workflow1.id);
|
||||
expect(sharedWorkflowIds).toContain(workflow2.id);
|
||||
});
|
||||
|
||||
it('should show shared workflows to users', async () => {
|
||||
const workflow1 = await createWorkflow({}, anotherMember);
|
||||
const workflow2 = await createWorkflow({}, anotherMember);
|
||||
const workflow3 = await createWorkflow({}, anotherMember);
|
||||
await shareWorkflowWithUsers(workflow1, [member]);
|
||||
await shareWorkflowWithUsers(workflow3, [member]);
|
||||
const sharedWorkflowIds = await workflowSharingService.getSharedWorkflowIds(member, {
|
||||
scopes: ['workflow:read'],
|
||||
});
|
||||
expect(sharedWorkflowIds).toHaveLength(2);
|
||||
expect(sharedWorkflowIds).toContain(workflow1.id);
|
||||
expect(sharedWorkflowIds).toContain(workflow3.id);
|
||||
expect(sharedWorkflowIds).not.toContain(workflow2.id);
|
||||
});
|
||||
|
||||
it('should show workflows that the user has access to through a team project they are part of', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const project = await projectService.createTeamProject(member, { name: 'Team Project' });
|
||||
await projectService.addUser(project.id, { userId: anotherMember.id, role: 'project:admin' });
|
||||
const workflow = await createWorkflow(undefined, project);
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const sharedWorkflowIds = await workflowSharingService.getSharedWorkflowIds(anotherMember, {
|
||||
scopes: ['workflow:read'],
|
||||
});
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(sharedWorkflowIds).toContain(workflow.id);
|
||||
});
|
||||
|
||||
it('should show workflows that the user has update access to', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const project1 = await projectService.createTeamProject(member, { name: 'Team Project 1' });
|
||||
const workflow1 = await createWorkflow(undefined, project1);
|
||||
const project2 = await projectService.createTeamProject(member, { name: 'Team Project 2' });
|
||||
const workflow2 = await createWorkflow(undefined, project2);
|
||||
await projectService.addUser(project1.id, {
|
||||
userId: anotherMember.id,
|
||||
role: 'project:admin',
|
||||
});
|
||||
await projectService.addUser(project2.id, {
|
||||
userId: anotherMember.id,
|
||||
role: 'project:viewer',
|
||||
});
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const sharedWorkflowIds = await workflowSharingService.getSharedWorkflowIds(anotherMember, {
|
||||
scopes: ['workflow:update'],
|
||||
});
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(sharedWorkflowIds).toContain(workflow1.id);
|
||||
expect(sharedWorkflowIds).not.toContain(workflow2.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { testDb, mockInstance } from '@n8n/backend-test-utils';
|
||||
import {
|
||||
CredentialsEntity,
|
||||
CredentialsRepository,
|
||||
SharedWorkflowRepository,
|
||||
WorkflowRepository,
|
||||
} from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { Telemetry } from '@/telemetry';
|
||||
import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
|
||||
|
||||
import {
|
||||
FIRST_CREDENTIAL_ID,
|
||||
SECOND_CREDENTIAL_ID,
|
||||
THIRD_CREDENTIAL_ID,
|
||||
getWorkflow,
|
||||
} from '../shared/workflow';
|
||||
|
||||
describe('EnterpriseWorkflowService', () => {
|
||||
let service: EnterpriseWorkflowService;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
mockInstance(Telemetry);
|
||||
|
||||
service = new EnterpriseWorkflowService(
|
||||
mock(),
|
||||
Container.get(SharedWorkflowRepository),
|
||||
Container.get(WorkflowRepository),
|
||||
Container.get(CredentialsRepository),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await testDb.truncate(['WorkflowEntity']);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('validateWorkflowCredentialUsage', () => {
|
||||
function generateCredentialEntity(credentialId: string) {
|
||||
const credentialEntity = new CredentialsEntity();
|
||||
credentialEntity.id = credentialId;
|
||||
return credentialEntity;
|
||||
}
|
||||
|
||||
it('Should throw error saving a workflow using credential without access', () => {
|
||||
const newWorkflowVersion = getWorkflow({ addNodeWithOneCred: true });
|
||||
const previousWorkflowVersion = getWorkflow();
|
||||
expect(() => {
|
||||
service.validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, []);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('Should not throw error when saving a workflow using credential with access', () => {
|
||||
const newWorkflowVersion = getWorkflow({ addNodeWithOneCred: true });
|
||||
const previousWorkflowVersion = getWorkflow();
|
||||
expect(() => {
|
||||
service.validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, [
|
||||
generateCredentialEntity('1'),
|
||||
]);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('Should not throw error when saving a workflow removing node without credential access', () => {
|
||||
const newWorkflowVersion = getWorkflow();
|
||||
const previousWorkflowVersion = getWorkflow({ addNodeWithOneCred: true });
|
||||
expect(() => {
|
||||
service.validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, [
|
||||
generateCredentialEntity('1'),
|
||||
]);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('Should save fine when not making changes to workflow without access', () => {
|
||||
const workflowWithOneCredential = getWorkflow({ addNodeWithOneCred: true });
|
||||
expect(() => {
|
||||
service.validateWorkflowCredentialUsage(
|
||||
workflowWithOneCredential,
|
||||
workflowWithOneCredential,
|
||||
[],
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('Should throw error saving a workflow adding node without credential access', () => {
|
||||
const newWorkflowVersion = getWorkflow({
|
||||
addNodeWithOneCred: true,
|
||||
addNodeWithTwoCreds: true,
|
||||
});
|
||||
const previousWorkflowVersion = getWorkflow({ addNodeWithOneCred: true });
|
||||
expect(() => {
|
||||
service.validateWorkflowCredentialUsage(newWorkflowVersion, previousWorkflowVersion, []);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodesWithInaccessibleCreds', () => {
|
||||
test('Should return an empty list for a workflow without nodes', () => {
|
||||
const workflow = getWorkflow();
|
||||
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []);
|
||||
expect(nodesWithInaccessibleCreds).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Should return an empty list for a workflow with nodes without credentials', () => {
|
||||
const workflow = getWorkflow({ addNodeWithoutCreds: true });
|
||||
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []);
|
||||
expect(nodesWithInaccessibleCreds).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Should return an element for a node with a credential without access', () => {
|
||||
const workflow = getWorkflow({ addNodeWithOneCred: true });
|
||||
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []);
|
||||
expect(nodesWithInaccessibleCreds).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Should return an empty list for a node with a credential with access', () => {
|
||||
const workflow = getWorkflow({ addNodeWithOneCred: true });
|
||||
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [
|
||||
FIRST_CREDENTIAL_ID,
|
||||
]);
|
||||
expect(nodesWithInaccessibleCreds).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Should return an element for a node with two credentials and mixed access', () => {
|
||||
const workflow = getWorkflow({ addNodeWithTwoCreds: true });
|
||||
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [
|
||||
SECOND_CREDENTIAL_ID,
|
||||
]);
|
||||
expect(nodesWithInaccessibleCreds).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Should return one node for a workflow with two nodes and two credentials', () => {
|
||||
const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true });
|
||||
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [
|
||||
SECOND_CREDENTIAL_ID,
|
||||
THIRD_CREDENTIAL_ID,
|
||||
]);
|
||||
expect(nodesWithInaccessibleCreds).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Should return one element for a workflows with two nodes and one credential', () => {
|
||||
const workflow = getWorkflow({
|
||||
addNodeWithoutCreds: true,
|
||||
addNodeWithOneCred: true,
|
||||
addNodeWithTwoCreds: true,
|
||||
});
|
||||
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [
|
||||
FIRST_CREDENTIAL_ID,
|
||||
]);
|
||||
expect(nodesWithInaccessibleCreds).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Should return one element for a workflows with two nodes and partial credential access', () => {
|
||||
const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true });
|
||||
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [
|
||||
FIRST_CREDENTIAL_ID,
|
||||
SECOND_CREDENTIAL_ID,
|
||||
]);
|
||||
expect(nodesWithInaccessibleCreds).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Should return two elements for a workflows with two nodes and partial credential access', () => {
|
||||
const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true });
|
||||
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, [
|
||||
SECOND_CREDENTIAL_ID,
|
||||
]);
|
||||
expect(nodesWithInaccessibleCreds).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('Should return two elements for a workflows with two nodes and no credential access', () => {
|
||||
const workflow = getWorkflow({ addNodeWithOneCred: true, addNodeWithTwoCreds: true });
|
||||
const nodesWithInaccessibleCreds = service.getNodesWithInaccessibleCreds(workflow, []);
|
||||
expect(nodesWithInaccessibleCreds).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,670 @@
|
||||
import {
|
||||
createWorkflowWithHistory,
|
||||
testDb,
|
||||
mockInstance,
|
||||
createActiveWorkflow,
|
||||
createTeamProject,
|
||||
linkUserToProject,
|
||||
createWorkflow,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import {
|
||||
SharedWorkflowRepository,
|
||||
type WorkflowEntity,
|
||||
WorkflowPublishHistoryRepository,
|
||||
WorkflowRepository,
|
||||
ProjectRepository,
|
||||
} from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
|
||||
import { WorkflowValidationService } from '@/workflows/workflow-validation.service';
|
||||
import { WorkflowService } from '@/workflows/workflow.service';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
import { RoleService } from '@/services/role.service';
|
||||
|
||||
import { createCustomRoleWithScopeSlugs, cleanupRolesAndScopes } from '../shared/db/roles';
|
||||
import { createOwner, createMember } from '../shared/db/users';
|
||||
import { createWorkflowHistoryItem } from '../shared/db/workflow-history';
|
||||
import { WebhookService } from '@/webhooks/webhook.service';
|
||||
|
||||
let globalConfig: GlobalConfig;
|
||||
let workflowRepository: WorkflowRepository;
|
||||
let workflowService: WorkflowService;
|
||||
let workflowPublishHistoryRepository: WorkflowPublishHistoryRepository;
|
||||
let workflowHistoryService: WorkflowHistoryService;
|
||||
const activeWorkflowManager = mockInstance(ActiveWorkflowManager);
|
||||
const workflowValidationService = mockInstance(WorkflowValidationService);
|
||||
const nodeTypes = mockInstance(NodeTypes);
|
||||
const webhookServiceMock = mockInstance(WebhookService);
|
||||
mockInstance(MessageEventBus);
|
||||
mockInstance(Telemetry);
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
|
||||
globalConfig = Container.get(GlobalConfig);
|
||||
workflowRepository = Container.get(WorkflowRepository);
|
||||
workflowPublishHistoryRepository = Container.get(WorkflowPublishHistoryRepository);
|
||||
workflowHistoryService = Container.get(WorkflowHistoryService);
|
||||
workflowService = new WorkflowService(
|
||||
mock(),
|
||||
Container.get(SharedWorkflowRepository),
|
||||
workflowRepository,
|
||||
mock(),
|
||||
mock(),
|
||||
Container.get(OwnershipService), // ownershipService
|
||||
mock(),
|
||||
workflowHistoryService,
|
||||
mock(),
|
||||
activeWorkflowManager,
|
||||
Container.get(RoleService), // roleService
|
||||
Container.get(ProjectService), // projectService
|
||||
mock(), // executionRepository
|
||||
mock(), // eventService
|
||||
globalConfig,
|
||||
mock(),
|
||||
Container.get(WorkflowFinderService),
|
||||
workflowPublishHistoryRepository,
|
||||
workflowValidationService,
|
||||
nodeTypes,
|
||||
webhookServiceMock,
|
||||
mock(), // licenseState
|
||||
Container.get(ProjectRepository), // projectRepository
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
workflowValidationService.validateForActivation.mockReturnValue({ isValid: true });
|
||||
workflowValidationService.validateSubWorkflowReferences.mockResolvedValue({ isValid: true });
|
||||
webhookServiceMock.findWebhookConflicts.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await testDb.truncate([
|
||||
'SharedWorkflow',
|
||||
'ProjectRelation',
|
||||
'WorkflowEntity',
|
||||
'WorkflowHistory',
|
||||
'WorkflowPublishHistory',
|
||||
'Project',
|
||||
'User',
|
||||
]);
|
||||
await cleanupRolesAndScopes();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('update()', () => {
|
||||
test('should save workflow history version with backfilled data when nodes change', async () => {
|
||||
const owner = await createOwner();
|
||||
const workflow = await createWorkflowWithHistory({}, owner);
|
||||
|
||||
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
|
||||
const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion');
|
||||
|
||||
const updateData = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'new-node',
|
||||
name: 'New Node',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [250, 300],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await workflowService.update(owner, updateData as WorkflowEntity, workflow.id, {
|
||||
forceSave: true,
|
||||
});
|
||||
|
||||
expect(saveVersionSpy).toHaveBeenCalledTimes(1);
|
||||
const [user, workflowData, workflowId] = saveVersionSpy.mock.calls[0];
|
||||
expect(user).toBe(owner);
|
||||
expect(workflowId).toBe(workflow.id);
|
||||
expect(workflowData.nodes).toEqual(updateData.nodes);
|
||||
// Verify that connections were backfilled from the DB
|
||||
expect(workflowData.connections).toEqual(workflow.connections);
|
||||
expect(workflowData.versionId).not.toBe(workflow.versionId);
|
||||
expect(addRecordSpy).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('should save workflow history version with backfilled data when connection change', async () => {
|
||||
const owner = await createOwner();
|
||||
const workflow = await createWorkflowWithHistory({}, owner);
|
||||
|
||||
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
|
||||
const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion');
|
||||
|
||||
const updateData = {
|
||||
connections: {
|
||||
'Manual Trigger': {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: 'Code Node',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await workflowService.update(owner, updateData as unknown as WorkflowEntity, workflow.id, {
|
||||
forceSave: true,
|
||||
});
|
||||
|
||||
expect(saveVersionSpy).toHaveBeenCalledTimes(1);
|
||||
const [user, workflowData, workflowId] = saveVersionSpy.mock.calls[0];
|
||||
expect(user).toBe(owner);
|
||||
expect(workflowId).toBe(workflow.id);
|
||||
expect(workflowData.connections).toEqual(updateData.connections);
|
||||
// Verify that nodes were backfilled from the DB
|
||||
expect(workflowData.nodes).toEqual(workflow.nodes);
|
||||
expect(workflowData.versionId).not.toBe(workflow.versionId);
|
||||
expect(addRecordSpy).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('activateWorkflow()', () => {
|
||||
test('should activate current workflow version if no version provided', async () => {
|
||||
const owner = await createOwner();
|
||||
const workflow = await createWorkflowWithHistory({}, owner);
|
||||
|
||||
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
|
||||
|
||||
const updatedWorkflow = await workflowService.activateWorkflow(owner, workflow.id);
|
||||
|
||||
expect(updatedWorkflow.active).toBe(true);
|
||||
expect(updatedWorkflow.activeVersionId).toBe(workflow.versionId);
|
||||
expect(updatedWorkflow.activeVersion).toBeDefined();
|
||||
expect(updatedWorkflow.activeVersion?.workflowPublishHistory).toHaveLength(1);
|
||||
expect(updatedWorkflow.activeVersion?.workflowPublishHistory[0]).toMatchObject({
|
||||
event: 'activated',
|
||||
versionId: workflow.versionId,
|
||||
});
|
||||
expect(addRecordSpy).toBeCalledWith({
|
||||
event: 'activated',
|
||||
workflowId: workflow.id,
|
||||
versionId: workflow.versionId,
|
||||
userId: owner.id,
|
||||
});
|
||||
});
|
||||
|
||||
test('should activate the provided workflow version', async () => {
|
||||
const owner = await createOwner();
|
||||
const workflow = await createWorkflowWithHistory({}, owner);
|
||||
|
||||
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
|
||||
|
||||
const newVersionId = uuid();
|
||||
await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId });
|
||||
|
||||
const updatedWorkflow = await workflowService.activateWorkflow(owner, workflow.id, {
|
||||
versionId: newVersionId,
|
||||
});
|
||||
|
||||
expect(updatedWorkflow.active).toBe(true);
|
||||
expect(updatedWorkflow.activeVersionId).toBe(newVersionId);
|
||||
expect(updatedWorkflow.versionId).toBe(workflow.versionId);
|
||||
expect(updatedWorkflow.activeVersion?.workflowPublishHistory).toHaveLength(1);
|
||||
expect(updatedWorkflow.activeVersion?.workflowPublishHistory[0]).toMatchObject({
|
||||
event: 'activated',
|
||||
versionId: newVersionId,
|
||||
});
|
||||
|
||||
expect(addRecordSpy).toBeCalledWith({
|
||||
event: 'activated',
|
||||
workflowId: workflow.id,
|
||||
versionId: newVersionId,
|
||||
userId: owner.id,
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw an error when webhook conflicts were found', async () => {
|
||||
const owner = await createOwner();
|
||||
const workflow = await createWorkflowWithHistory({}, owner);
|
||||
const newVersionId = uuid();
|
||||
await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId });
|
||||
|
||||
webhookServiceMock.findWebhookConflicts.mockResolvedValue([
|
||||
{
|
||||
trigger: {
|
||||
id: '',
|
||||
name: '',
|
||||
typeVersion: 0,
|
||||
type: '',
|
||||
position: [1, 2],
|
||||
parameters: {},
|
||||
},
|
||||
conflict: {
|
||||
webhookId: 'some-id',
|
||||
webhookPath: 'some-path',
|
||||
workflowId: 'workflow-123',
|
||||
method: 'GET',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
workflowService.activateWorkflow(owner, workflow.id, {
|
||||
versionId: newVersionId,
|
||||
}),
|
||||
).rejects.toThrow('There is a conflict with one of the webhooks.');
|
||||
});
|
||||
|
||||
test('should use nodes from correct workflow version when checking conflicts and versionId is passed', async () => {
|
||||
const owner = await createOwner();
|
||||
const oldVersionId = uuid();
|
||||
const oldNodes: INode[] = [
|
||||
{
|
||||
id: '123',
|
||||
webhookId: 'version1',
|
||||
name: 'test',
|
||||
typeVersion: 0,
|
||||
type: '',
|
||||
position: [1, 2],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: '345',
|
||||
webhookId: 'version1-2',
|
||||
name: 'test2',
|
||||
typeVersion: 0,
|
||||
type: '',
|
||||
position: [1, 2],
|
||||
parameters: {},
|
||||
},
|
||||
];
|
||||
const workflow = await createWorkflowWithHistory(
|
||||
{
|
||||
nodes: oldNodes,
|
||||
versionId: oldVersionId,
|
||||
},
|
||||
owner,
|
||||
);
|
||||
|
||||
const newVersionId = uuid();
|
||||
const newNodes: INode[] = [
|
||||
{
|
||||
id: '123',
|
||||
webhookId: 'version2',
|
||||
name: '',
|
||||
typeVersion: 0,
|
||||
type: '',
|
||||
position: [1, 2],
|
||||
parameters: {},
|
||||
},
|
||||
];
|
||||
await workflowService.update(
|
||||
owner,
|
||||
{
|
||||
nodes: newNodes,
|
||||
} as WorkflowEntity,
|
||||
workflow.id,
|
||||
);
|
||||
await createWorkflowHistoryItem(workflow.id, {
|
||||
versionId: newVersionId,
|
||||
nodes: [
|
||||
{
|
||||
id: '123',
|
||||
webhookId: 'version2',
|
||||
name: 'newNode',
|
||||
typeVersion: 0,
|
||||
type: '',
|
||||
position: [1, 2],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await workflowService.activateWorkflow(owner, workflow.id, {
|
||||
versionId: oldVersionId,
|
||||
});
|
||||
|
||||
expect(webhookServiceMock.findWebhookConflicts.mock.calls[0][0].nodes).toEqual(
|
||||
oldNodes.reduce((res, node) => ({ ...res, [node.name]: node }), {}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should use nodes from latest workflow version when checking conflicts and no versionId is passed', async () => {
|
||||
const owner = await createOwner();
|
||||
const oldNodes: INode[] = [
|
||||
{
|
||||
id: '123',
|
||||
webhookId: 'version1',
|
||||
name: 'test',
|
||||
typeVersion: 0,
|
||||
type: '',
|
||||
position: [1, 2],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: '345',
|
||||
webhookId: 'version1-2',
|
||||
name: 'test2',
|
||||
typeVersion: 0,
|
||||
type: '',
|
||||
position: [1, 2],
|
||||
parameters: {},
|
||||
},
|
||||
];
|
||||
const workflow = await createWorkflowWithHistory(
|
||||
{
|
||||
nodes: oldNodes,
|
||||
versionId: uuid(),
|
||||
},
|
||||
owner,
|
||||
);
|
||||
|
||||
const newNodes: INode[] = [
|
||||
{
|
||||
id: '123',
|
||||
webhookId: 'version2',
|
||||
name: 'newNode',
|
||||
typeVersion: 0,
|
||||
type: '',
|
||||
position: [1, 2],
|
||||
parameters: {},
|
||||
},
|
||||
];
|
||||
await workflowService.update(
|
||||
owner,
|
||||
{
|
||||
nodes: newNodes,
|
||||
} as WorkflowEntity,
|
||||
workflow.id,
|
||||
);
|
||||
|
||||
await workflowService.activateWorkflow(owner, workflow.id, {});
|
||||
|
||||
expect(webhookServiceMock.findWebhookConflicts.mock.calls[0][0].nodes).toEqual(
|
||||
newNodes.reduce((res, node) => ({ ...res, [node.name]: node }), {}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should not activate workflow if validation fails and keep old active version', async () => {
|
||||
const owner = await createOwner();
|
||||
const workflow = await createActiveWorkflow({}, owner);
|
||||
|
||||
const oldActiveVersionId = workflow.activeVersionId;
|
||||
|
||||
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
|
||||
|
||||
// Create a new version to try to activate
|
||||
const newVersionId = uuid();
|
||||
await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId });
|
||||
|
||||
// Mock validation to fail
|
||||
workflowValidationService.validateForActivation.mockReturnValue({
|
||||
isValid: false,
|
||||
error: 'Workflow cannot be activated because it has no trigger node.',
|
||||
});
|
||||
|
||||
await expect(
|
||||
workflowService.activateWorkflow(owner, workflow.id, {
|
||||
versionId: newVersionId,
|
||||
}),
|
||||
).rejects.toThrow('Workflow cannot be activated because it has no trigger node.');
|
||||
|
||||
// Verify no publish history was added
|
||||
expect(addRecordSpy).not.toBeCalled();
|
||||
|
||||
// Verify the workflow still has the old active version
|
||||
const workflowAfter = await workflowRepository.findOne({ where: { id: workflow.id } });
|
||||
expect(workflowAfter?.activeVersionId).toBe(oldActiveVersionId);
|
||||
expect(workflowAfter?.active).toBe(true);
|
||||
});
|
||||
|
||||
test('should not activate workflow without workflow:publish permission', async () => {
|
||||
const owner = await createOwner();
|
||||
const member = await createMember();
|
||||
|
||||
// custom role with workflow:update but not workflow:publish
|
||||
const customRole = await createCustomRoleWithScopeSlugs(['workflow:read', 'workflow:update'], {
|
||||
roleType: 'project',
|
||||
displayName: 'Custom Workflow Updater',
|
||||
description: 'Can update workflows but not publish them',
|
||||
});
|
||||
|
||||
const project = await createTeamProject('Test Project', owner);
|
||||
await linkUserToProject(member, project, customRole.slug);
|
||||
|
||||
const workflow = await createWorkflowWithHistory({}, project);
|
||||
|
||||
await expect(workflowService.activateWorkflow(member, workflow.id)).rejects.toThrow(
|
||||
'You do not have permission to activate this workflow. Ask the owner to share it with you.',
|
||||
);
|
||||
|
||||
const workflowAfter = await workflowRepository.findOne({ where: { id: workflow.id } });
|
||||
expect(workflowAfter?.active).toBe(false);
|
||||
expect(workflowAfter?.activeVersionId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deactivateWorkflow()', () => {
|
||||
test('should not deactivate workflow without workflow:unpublish permission', async () => {
|
||||
const owner = await createOwner();
|
||||
const member = await createMember();
|
||||
|
||||
// custom role with workflow:update but not workflow:unpublish
|
||||
const customRole = await createCustomRoleWithScopeSlugs(['workflow:read', 'workflow:update'], {
|
||||
roleType: 'project',
|
||||
displayName: 'Custom Workflow Updater',
|
||||
description: 'Can update workflows but not unpublish them',
|
||||
});
|
||||
|
||||
const project = await createTeamProject('Test Project', owner);
|
||||
await linkUserToProject(member, project, customRole.slug);
|
||||
|
||||
const workflow = await createActiveWorkflow({}, project);
|
||||
|
||||
await expect(workflowService.deactivateWorkflow(member, workflow.id)).rejects.toThrow(
|
||||
'You do not have permission to deactivate this workflow. Ask the owner to share it with you.',
|
||||
);
|
||||
|
||||
// Verify workflow is still active
|
||||
const workflowAfter = await workflowRepository.findOne({ where: { id: workflow.id } });
|
||||
expect(workflowAfter?.active).toBe(true);
|
||||
expect(workflowAfter?.activeVersionId).toBe(workflow.activeVersionId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMany()', () => {
|
||||
describe('filtering by personal project', () => {
|
||||
test('should return empty when regular user queries another users personal project', async () => {
|
||||
const member1 = await createMember();
|
||||
const member2 = await createMember();
|
||||
|
||||
const projectRepository = Container.get(ProjectRepository);
|
||||
const member2PersonalProject = await projectRepository.getPersonalProjectForUserOrFail(
|
||||
member2.id,
|
||||
);
|
||||
|
||||
// member2 owns some workflows in their personal project
|
||||
await createWorkflow({ name: 'Member2 Private Workflow 1' }, member2);
|
||||
await createWorkflow({ name: 'Member2 Private Workflow 2' }, member2);
|
||||
|
||||
// member1 (who has NO relation to member2's personal project) tries to query member2's personal project
|
||||
const result = await workflowService.getMany(
|
||||
member1,
|
||||
{ filter: { projectId: member2PersonalProject.id } },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
// SECURITY: member1 should NOT see any of member2's workflows
|
||||
expect(result.workflows).toHaveLength(0);
|
||||
expect(result.count).toBe(0);
|
||||
});
|
||||
|
||||
test('should allow admin with global workflow:read to query another users personal project', async () => {
|
||||
const owner = await createOwner(); // Owner has global workflow:read scope
|
||||
const member = await createMember();
|
||||
|
||||
const projectRepository = Container.get(ProjectRepository);
|
||||
const memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(
|
||||
member.id,
|
||||
);
|
||||
|
||||
// member owns some workflows in their personal project
|
||||
const workflow1 = await createWorkflow({ name: 'Member Private Workflow 1' }, member);
|
||||
const workflow2 = await createWorkflow({ name: 'Member Private Workflow 2' }, member);
|
||||
|
||||
// owner (with global workflow:read) can query member's personal project
|
||||
const result = await workflowService.getMany(
|
||||
owner,
|
||||
{ filter: { projectId: memberPersonalProject.id } },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
// Admin with global scope CAN see the workflows
|
||||
expect(result.workflows).toHaveLength(2);
|
||||
expect(result.count).toBe(2);
|
||||
const workflowIds = result.workflows.map((w) => w.id).sort();
|
||||
expect(workflowIds).toEqual([workflow1.id, workflow2.id].sort());
|
||||
});
|
||||
|
||||
test('should return only workflows owned by user in their personal project', async () => {
|
||||
const owner = await createOwner();
|
||||
const member = await createMember();
|
||||
|
||||
const projectRepository = Container.get(ProjectRepository);
|
||||
const memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(
|
||||
member.id,
|
||||
);
|
||||
|
||||
const memberOwnedWorkflow = await createWorkflow({ name: 'Member Owned Workflow' }, member);
|
||||
const sharedWorkflow = await createWorkflow({ name: 'Shared Workflow' }, owner);
|
||||
await Container.get(SharedWorkflowRepository).save(
|
||||
Container.get(SharedWorkflowRepository).create({
|
||||
projectId: memberPersonalProject.id,
|
||||
workflowId: sharedWorkflow.id,
|
||||
role: 'workflow:editor',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await workflowService.getMany(
|
||||
owner,
|
||||
{ filter: { projectId: memberPersonalProject.id } },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.workflows).toHaveLength(1);
|
||||
expect(result.workflows[0].id).toBe(memberOwnedWorkflow.id);
|
||||
expect(result.workflows[0].name).toBe('Member Owned Workflow');
|
||||
expect(result.count).toBe(1);
|
||||
});
|
||||
|
||||
test('should return empty when filtering by personal project of user with no owned workflows', async () => {
|
||||
const owner = await createOwner();
|
||||
const member = await createMember();
|
||||
|
||||
const projectRepository = Container.get(ProjectRepository);
|
||||
const memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(
|
||||
member.id,
|
||||
);
|
||||
|
||||
const sharedWorkflow = await createWorkflow({ name: 'Shared Workflow' }, owner);
|
||||
await Container.get(SharedWorkflowRepository).save(
|
||||
Container.get(SharedWorkflowRepository).create({
|
||||
projectId: memberPersonalProject.id,
|
||||
workflowId: sharedWorkflow.id,
|
||||
role: 'workflow:editor',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await workflowService.getMany(
|
||||
owner,
|
||||
{ filter: { projectId: memberPersonalProject.id } },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.workflows).toHaveLength(0);
|
||||
expect(result.count).toBe(0);
|
||||
});
|
||||
|
||||
test('should return empty when filtering by non-existent project', async () => {
|
||||
const owner = await createOwner();
|
||||
|
||||
const result = await workflowService.getMany(
|
||||
owner,
|
||||
{ filter: { projectId: 'non-existent-project-id' } },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.workflows).toHaveLength(0);
|
||||
expect(result.count).toBe(0);
|
||||
});
|
||||
|
||||
test('should return user owned workflows when user queries their own personal project', async () => {
|
||||
const member = await createMember();
|
||||
|
||||
const projectRepository = Container.get(ProjectRepository);
|
||||
const memberPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(
|
||||
member.id,
|
||||
);
|
||||
|
||||
const workflow1 = await createWorkflow({ name: 'Workflow 1' }, member);
|
||||
const workflow2 = await createWorkflow({ name: 'Workflow 2' }, member);
|
||||
|
||||
const result = await workflowService.getMany(
|
||||
member,
|
||||
{ filter: { projectId: memberPersonalProject.id } },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.workflows).toHaveLength(2);
|
||||
expect(result.count).toBe(2);
|
||||
const workflowIds = result.workflows.map((w) => w.id).sort();
|
||||
expect(workflowIds).toEqual([workflow1.id, workflow2.id].sort());
|
||||
});
|
||||
|
||||
test('should handle team project filtering correctly', async () => {
|
||||
const owner = await createOwner();
|
||||
const member = await createMember();
|
||||
|
||||
const teamProject = await createTeamProject('Team Project', owner);
|
||||
await linkUserToProject(member, teamProject, 'project:editor');
|
||||
|
||||
const teamWorkflow1 = await createWorkflow({ name: 'Team Workflow 1' }, teamProject);
|
||||
const teamWorkflow2 = await createWorkflow({ name: 'Team Workflow 2' }, teamProject);
|
||||
|
||||
const result = await workflowService.getMany(
|
||||
member,
|
||||
{ filter: { projectId: teamProject.id } },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.workflows).toHaveLength(2);
|
||||
expect(result.count).toBe(2);
|
||||
const workflowIds = result.workflows.map((w) => w.id).sort();
|
||||
expect(workflowIds).toEqual([teamWorkflow1.id, teamWorkflow2.id].sort());
|
||||
});
|
||||
});
|
||||
});
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
createTeamProject,
|
||||
testDb,
|
||||
mockInstance,
|
||||
createActiveWorkflow,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import type { User } from '@n8n/db';
|
||||
|
||||
import { Telemetry } from '@/telemetry';
|
||||
|
||||
import { createUser } from '../shared/db/users';
|
||||
import * as utils from '../shared/utils/';
|
||||
|
||||
mockInstance(Telemetry);
|
||||
|
||||
let member: User;
|
||||
|
||||
const testServer = utils.setupTestServer({
|
||||
endpointGroups: ['workflows'],
|
||||
enabledFeatures: ['feat:sharing', 'feat:advancedPermissions'],
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
member = await createUser({ role: { slug: 'global:member' } });
|
||||
|
||||
await utils.initNodeTypes();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate([
|
||||
'WorkflowEntity',
|
||||
'SharedWorkflow',
|
||||
'WorkflowHistory',
|
||||
'WorkflowPublishHistory',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('PUT /:workflowId/transfer', () => {
|
||||
// This tests does not mock the ActiveWorkflowManager, which helps catching
|
||||
// possible deadlocks when using transactions wrong.
|
||||
test('can transfer an active workflow', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const destinationProject = await createTeamProject('Team Project', member);
|
||||
|
||||
const workflow = await createActiveWorkflow({}, member);
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const response = await testServer
|
||||
.authAgentFor(member)
|
||||
.put(`/workflows/${workflow.id}/transfer`)
|
||||
.send({ destinationProjectId: destinationProject.id })
|
||||
.expect(200);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(response.body).toEqual({});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user