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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,308 @@
import { getPersonalProject, mockInstance, testDb } from '@n8n/backend-test-utils';
import { nanoid } from 'nanoid';
import '@/zod-alias-support';
import { ImportCredentialsCommand } from '@/commands/import/credentials';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { setupTestCommand } from '@test-integration/utils/test-command';
import { getAllCredentials, getAllSharedCredentials } from '../shared/db/credentials';
import { createMember, createOwner } from '../shared/db/users';
mockInstance(LoadNodesAndCredentials);
const command = setupTestCommand(ImportCredentialsCommand);
beforeEach(async () => {
await testDb.truncate(['CredentialsEntity', 'SharedCredentials', 'User']);
});
test('import:credentials should import a credential', async () => {
//
// ARRANGE
//
const owner = await createOwner();
const ownerProject = await getPersonalProject(owner);
//
// ACT
//
await command.run(['--input=./test/integration/commands/import-credentials/credentials.json']);
//
// ASSERT
//
const after = {
credentials: await getAllCredentials(),
sharings: await getAllSharedCredentials(),
};
expect(after).toMatchObject({
credentials: [expect.objectContaining({ id: '123', name: 'cred-aws-test' })],
sharings: [
expect.objectContaining({
credentialsId: '123',
projectId: ownerProject.id,
role: 'credential:owner',
}),
],
});
});
test('import:credentials should import a credential from separated files', async () => {
//
// ARRANGE
//
const owner = await createOwner();
const ownerProject = await getPersonalProject(owner);
//
// ACT
//
// import credential the first time, assigning it to the owner
await command.run([
'--separate',
'--input=./test/integration/commands/import-credentials/separate',
]);
//
// ASSERT
//
const after = {
credentials: await getAllCredentials(),
sharings: await getAllSharedCredentials(),
};
expect(after).toMatchObject({
credentials: [
expect.objectContaining({
id: '123',
name: 'cred-aws-test',
}),
],
sharings: [
expect.objectContaining({
credentialsId: '123',
projectId: ownerProject.id,
role: 'credential:owner',
}),
],
});
});
test('`import:credentials --userId ...` should fail if the credential exists already and is owned by somebody else', async () => {
//
// ARRANGE
//
const owner = await createOwner();
const ownerProject = await getPersonalProject(owner);
const member = await createMember();
// import credential the first time, assigning it to the owner
await command.run([
'--input=./test/integration/commands/import-credentials/credentials.json',
`--userId=${owner.id}`,
]);
// making sure the import worked
const before = {
credentials: await getAllCredentials(),
sharings: await getAllSharedCredentials(),
};
expect(before).toMatchObject({
credentials: [expect.objectContaining({ id: '123', name: 'cred-aws-test' })],
sharings: [
expect.objectContaining({
credentialsId: '123',
projectId: ownerProject.id,
role: 'credential:owner',
}),
],
});
//
// ACT
//
// Import again while updating the name we try to assign the
// credential to another user.
await expect(
command.run([
'--input=./test/integration/commands/import-credentials/credentials-updated.json',
`--userId=${member.id}`,
]),
).rejects.toThrowError(
`The credential with ID "123" is already owned by the user with the ID "${owner.id}". It can't be re-owned by the user with the ID "${member.id}"`,
);
//
// ASSERT
//
const after = {
credentials: await getAllCredentials(),
sharings: await getAllSharedCredentials(),
};
expect(after).toMatchObject({
credentials: [
expect.objectContaining({
id: '123',
// only the name was updated
name: 'cred-aws-test',
}),
],
sharings: [
expect.objectContaining({
credentialsId: '123',
projectId: ownerProject.id,
role: 'credential:owner',
}),
],
});
});
test("only update credential, don't create or update owner if neither `--userId` nor `--projectId` is passed", async () => {
//
// ARRANGE
//
await createOwner();
const member = await createMember();
const memberProject = await getPersonalProject(member);
// import credential the first time, assigning it to a member
await command.run([
'--input=./test/integration/commands/import-credentials/credentials.json',
`--userId=${member.id}`,
]);
// making sure the import worked
const before = {
credentials: await getAllCredentials(),
sharings: await getAllSharedCredentials(),
};
expect(before).toMatchObject({
credentials: [expect.objectContaining({ id: '123', name: 'cred-aws-test' })],
sharings: [
expect.objectContaining({
credentialsId: '123',
projectId: memberProject.id,
role: 'credential:owner',
}),
],
});
//
// ACT
//
// Import again only updating the name and omitting `--userId`
await command.run([
'--input=./test/integration/commands/import-credentials/credentials-updated.json',
]);
//
// ASSERT
//
const after = {
credentials: await getAllCredentials(),
sharings: await getAllSharedCredentials(),
};
expect(after).toMatchObject({
credentials: [
expect.objectContaining({
id: '123',
// only the name was updated
name: 'cred-aws-prod',
}),
],
sharings: [
expect.objectContaining({
credentialsId: '123',
projectId: memberProject.id,
role: 'credential:owner',
}),
],
});
});
test('`import:credential --projectId ...` should fail if the credential already exists and is owned by another project', async () => {
//
// ARRANGE
//
const owner = await createOwner();
const ownerProject = await getPersonalProject(owner);
const member = await createMember();
const memberProject = await getPersonalProject(member);
// import credential the first time, assigning it to the owner
await command.run([
'--input=./test/integration/commands/import-credentials/credentials.json',
`--userId=${owner.id}`,
]);
// making sure the import worked
const before = {
credentials: await getAllCredentials(),
sharings: await getAllSharedCredentials(),
};
expect(before).toMatchObject({
credentials: [expect.objectContaining({ id: '123', name: 'cred-aws-test' })],
sharings: [
expect.objectContaining({
credentialsId: '123',
projectId: ownerProject.id,
role: 'credential:owner',
}),
],
});
//
// ACT
//
// Import again while updating the name we try to assign the
// credential to another user.
await expect(
command.run([
'--input=./test/integration/commands/import-credentials/credentials-updated.json',
`--projectId=${memberProject.id}`,
]),
).rejects.toThrowError(
`The credential with ID "123" is already owned by the user with the ID "${owner.id}". It can't be re-owned by the project with the ID "${memberProject.id}".`,
);
//
// ASSERT
//
const after = {
credentials: await getAllCredentials(),
sharings: await getAllSharedCredentials(),
};
expect(after).toMatchObject({
credentials: [
expect.objectContaining({
id: '123',
// only the name was updated
name: 'cred-aws-test',
}),
],
sharings: [
expect.objectContaining({
credentialsId: '123',
projectId: ownerProject.id,
role: 'credential:owner',
}),
],
});
});
test('`import:credential --projectId ... --userId ...` fails explaining that only one of the options can be used at a time', async () => {
await expect(
command.run([
'--input=./test/integration/commands/import-credentials/credentials-updated.json',
`--projectId=${nanoid()}`,
`--userId=${nanoid()}`,
]),
).rejects.toThrowError(
'You cannot use `--userId` and `--projectId` together. Use one or the other.',
);
});
@@ -0,0 +1,403 @@
import {
mockInstance,
testDb,
createWorkflowWithTriggerAndHistory,
setActiveVersion,
createWorkflowHistory,
} from '@n8n/backend-test-utils';
import { WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import fs from 'fs';
import { nanoid } from 'nanoid';
import os from 'os';
import path from 'path';
import { ExportWorkflowsCommand } from '@/commands/export/workflow';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { setupTestCommand } from '@test-integration/utils/test-command';
mockInstance(LoadNodesAndCredentials);
const command = setupTestCommand(ExportWorkflowsCommand);
let testOutputDir: string;
beforeEach(async () => {
await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']);
testOutputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'n8n-export-test-'));
});
afterEach(() => {
if (fs.existsSync(testOutputDir)) {
fs.rmSync(testOutputDir, { recursive: true, force: true });
}
});
test('should reject both --version and --published flags', async () => {
const workflow = await createWorkflowWithTriggerAndHistory();
const outputFile = path.join(testOutputDir, 'output.json');
await command.run([
`--id=${workflow.id}`,
`--version=${workflow.versionId}`,
'--published',
`--output=${outputFile}`,
]);
expect(fs.existsSync(outputFile)).toBe(false);
});
test('should reject --version with --all flag', async () => {
const workflow = await createWorkflowWithTriggerAndHistory();
const outputFile = path.join(testOutputDir, 'output.json');
await command.run(['--all', `--version=${workflow.versionId}`, `--output=${outputFile}`]);
expect(fs.existsSync(outputFile)).toBe(false);
});
test('should export current draft version when no flags set', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({
name: 'Test Workflow',
nodes: [
{
id: 'uuid-draft',
parameters: {},
name: 'Draft Node',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [240, 300],
},
],
});
const outputFile = path.join(testOutputDir, 'output.json');
await command.run([`--id=${workflow.id}`, `--output=${outputFile}`]);
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'))[0];
expect(exportedData).toMatchObject({
id: workflow.id,
name: 'Test Workflow',
versionId: workflow.versionId,
});
expect(exportedData.nodes[0].name).toBe('Draft Node');
});
test('should export specified version with --version flag', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({
name: 'Test Workflow',
nodes: [
{
id: 'uuid-v1',
parameters: {},
name: 'Version 1 Node',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [240, 300],
},
],
});
const version1Id = workflow.versionId;
const newVersionId = nanoid();
workflow.versionId = newVersionId;
workflow.nodes = [
{
id: 'uuid-v2',
parameters: {},
name: 'Version 2 Node',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [240, 300],
},
];
await Container.get(WorkflowRepository).save(workflow);
await createWorkflowHistory(workflow);
const outputFile = path.join(testOutputDir, 'output.json');
await command.run([`--id=${workflow.id}`, `--version=${version1Id}`, `--output=${outputFile}`]);
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'))[0];
expect(exportedData).toMatchObject({
id: workflow.id,
versionId: version1Id,
});
expect(exportedData.nodes[0].name).toBe('Version 1 Node');
});
test('should export published version with --published flag', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({
name: 'Test Workflow',
nodes: [
{
id: 'uuid-published',
parameters: {},
name: 'Published Node',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [240, 300],
},
],
});
const publishedVersionId = workflow.versionId;
await setActiveVersion(workflow.id, publishedVersionId);
const draftVersionId = nanoid();
workflow.versionId = draftVersionId;
workflow.activeVersionId = publishedVersionId;
workflow.nodes = [
{
id: 'uuid-draft',
parameters: {},
name: 'Draft Node',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [240, 300],
},
];
await Container.get(WorkflowRepository).save(workflow);
await createWorkflowHistory(workflow);
const outputFile = path.join(testOutputDir, 'output.json');
await command.run([`--id=${workflow.id}`, '--published', `--output=${outputFile}`]);
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'))[0];
expect(exportedData).toMatchObject({
id: workflow.id,
versionId: publishedVersionId,
});
expect(exportedData.nodes[0].name).toBe('Published Node');
});
test('should optimize when target version is current draft', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({
name: 'Test Workflow',
});
const outputFile = path.join(testOutputDir, 'output.json');
await command.run([
`--id=${workflow.id}`,
`--version=${workflow.versionId}`,
`--output=${outputFile}`,
]);
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'))[0];
expect(exportedData).toMatchObject({
id: workflow.id,
versionId: workflow.versionId,
});
});
test('should merge historical nodes with current metadata', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({
name: 'Original Name',
nodes: [
{
id: 'uuid-v1',
parameters: {},
name: 'Version 1 Node',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [240, 300],
},
],
});
const version1Id = workflow.versionId;
const newVersionId = nanoid();
workflow.versionId = newVersionId;
workflow.name = 'Updated Name';
workflow.nodes = [
{
id: 'uuid-v2',
parameters: {},
name: 'Version 2 Node',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [240, 300],
},
];
await Container.get(WorkflowRepository).save(workflow);
await createWorkflowHistory(workflow);
const outputFile = path.join(testOutputDir, 'output.json');
await command.run([`--id=${workflow.id}`, `--version=${version1Id}`, `--output=${outputFile}`]);
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'))[0];
expect(exportedData.nodes[0].name).toBe('Version 1 Node');
expect(exportedData.versionId).toBe(version1Id);
expect(exportedData.name).toBe('Updated Name');
});
test('should error when version not found', async () => {
const workflow = await createWorkflowWithTriggerAndHistory();
const nonExistentVersionId = 'non-existent-version';
await expect(
command.run([`--id=${workflow.id}`, `--version=${nonExistentVersionId}`]),
).rejects.toThrow(
`Version "${nonExistentVersionId}" not found for workflow "${workflow.name}" (${workflow.id})`,
);
});
test('should error when --published used on unpublished workflow', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({
name: 'Unpublished Workflow',
});
workflow.activeVersionId = null;
await Container.get(WorkflowRepository).save(workflow);
await expect(command.run([`--id=${workflow.id}`, '--published'])).rejects.toThrow(
`No published version found for workflow "${workflow.name}" (${workflow.id})`,
);
});
test('should error when workflow not found', async () => {
const nonExistentId = 'non-existent-id';
await expect(command.run([`--id=${nonExistentId}`])).rejects.toThrow(
'No workflows found with specified filters',
);
});
test('should work without any version flags (existing behavior)', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({
name: 'Test Workflow',
});
const outputFile = path.join(testOutputDir, 'output.json');
await command.run([`--id=${workflow.id}`, `--output=${outputFile}`]);
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'))[0];
expect(exportedData).toMatchObject({
id: workflow.id,
name: 'Test Workflow',
versionId: workflow.versionId,
});
});
test('should work with --all flag (existing behavior)', async () => {
await createWorkflowWithTriggerAndHistory({ name: 'Workflow 1' });
await createWorkflowWithTriggerAndHistory({ name: 'Workflow 2' });
const outputFile = path.join(testOutputDir, 'output.json');
await command.run(['--all', `--output=${outputFile}`]);
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'));
expect(exportedData).toHaveLength(2);
const workflowNames = exportedData.map((w: any) => w.name);
expect(workflowNames).toContain('Workflow 1');
expect(workflowNames).toContain('Workflow 2');
});
test('should work with --pretty flag (existing behavior)', async () => {
const workflow = await createWorkflowWithTriggerAndHistory();
const outputFile = path.join(testOutputDir, 'output.json');
await command.run([`--id=${workflow.id}`, '--pretty', `--output=${outputFile}`]);
const fileContents = fs.readFileSync(outputFile, 'utf-8');
expect(fileContents).toContain('\n');
expect(fileContents).toMatch(/\s{2}/);
});
test('should export all published versions with --all --published', async () => {
const workflow1 = await createWorkflowWithTriggerAndHistory({ name: 'Published Workflow' });
const publishedVersionId = workflow1.versionId;
await setActiveVersion(workflow1.id, publishedVersionId);
const draftVersionId = nanoid();
workflow1.versionId = draftVersionId;
workflow1.activeVersionId = publishedVersionId;
await Container.get(WorkflowRepository).save(workflow1);
await createWorkflowHistory(workflow1);
await createWorkflowWithTriggerAndHistory({ name: 'Unpublished Workflow' });
const outputFile = path.join(testOutputDir, 'output.json');
await command.run(['--all', '--published', `--output=${outputFile}`]);
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'));
expect(exportedData).toHaveLength(1);
expect(exportedData[0].name).toBe('Published Workflow');
expect(exportedData[0].versionId).toBe(publishedVersionId);
});
test('should include versionMetadata with historical name when set', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({
name: 'Original Name',
});
const version2Id = nanoid();
workflow.versionId = version2Id;
workflow.name = 'Updated Name';
await Container.get(WorkflowRepository).save(workflow);
await createWorkflowHistory(workflow, undefined, undefined, {
name: 'Version 2 Historical Name',
});
const version3Id = nanoid();
workflow.versionId = version3Id;
workflow.name = 'Current Name';
await Container.get(WorkflowRepository).save(workflow);
await createWorkflowHistory(workflow);
const outputFile = path.join(testOutputDir, 'output.json');
await command.run([`--id=${workflow.id}`, `--version=${version2Id}`, `--output=${outputFile}`]);
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'))[0];
expect(exportedData.name).toBe('Current Name');
expect(exportedData.versionId).toBe(version2Id);
expect(exportedData.versionMetadata).toEqual({
name: 'Version 2 Historical Name',
description: null,
});
});
test('should include versionMetadata with historical description when set', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({
description: 'Original Description',
});
const version2Id = nanoid();
workflow.versionId = version2Id;
workflow.description = 'Updated Description';
await Container.get(WorkflowRepository).save(workflow);
await createWorkflowHistory(workflow, undefined, undefined, {
description: 'Version 2 Historical Description',
});
const version3Id = nanoid();
workflow.versionId = version3Id;
workflow.description = 'Current Description';
await Container.get(WorkflowRepository).save(workflow);
await createWorkflowHistory(workflow);
const outputFile = path.join(testOutputDir, 'output.json');
await command.run([`--id=${workflow.id}`, `--version=${version2Id}`, `--output=${outputFile}`]);
const exportedData = JSON.parse(fs.readFileSync(outputFile, 'utf-8'))[0];
expect(exportedData.description).toBe('Current Description');
expect(exportedData.versionId).toBe(version2Id);
expect(exportedData.versionMetadata).toEqual({
name: null,
description: 'Version 2 Historical Description',
});
});
@@ -0,0 +1,14 @@
[
{
"createdAt": "2023-07-10T14:50:49.193Z",
"updatedAt": "2023-10-27T13:34:42.917Z",
"id": "123",
"name": "cred-aws-prod",
"data": {
"region": "eu-west-1",
"accessKeyId": "999999999999",
"secretAccessKey": "aaaaaaaaaaaaa"
},
"type": "aws"
}
]
@@ -0,0 +1,14 @@
[
{
"createdAt": "2023-07-10T14:50:49.193Z",
"updatedAt": "2023-10-27T13:34:42.917Z",
"id": "123",
"name": "cred-aws-test",
"data": {
"region": "eu-west-1",
"accessKeyId": "999999999999",
"secretAccessKey": "aaaaaaaaaaaaa"
},
"type": "aws"
}
]
@@ -0,0 +1,12 @@
{
"createdAt": "2023-07-10T14:50:49.193Z",
"updatedAt": "2023-10-27T13:34:42.917Z",
"id": "123",
"name": "cred-aws-test",
"data": {
"region": "eu-west-1",
"accessKeyId": "999999999999",
"secretAccessKey": "aaaaaaaaaaaaa"
},
"type": "aws"
}
@@ -0,0 +1,81 @@
[
{
"name": "active-workflow",
"nodes": [
{
"parameters": {
"path": "e20b4873-fcf7-4bce-88fc-a1a56d66b138",
"responseMode": "responseNode",
"options": {}
},
"id": "c26d8782-bd57-43d0-86dc-0c618a7e4024",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [800, 580],
"webhookId": "e20b4873-fcf7-4bce-88fc-a1a56d66b138"
},
{
"parameters": {
"values": {
"boolean": [
{
"name": "hooked",
"value": true
}
]
},
"options": {}
},
"id": "9701b1ef-9ab0-432a-b086-cf76981b097d",
"name": "Set",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [1020, 580]
},
{
"parameters": {
"options": {}
},
"id": "d0f086b8-c2b2-4404-b347-95d3f91e555a",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [1240, 580]
}
],
"pinData": {},
"connections": {
"Webhook": {
"main": [
[
{
"node": "Set",
"type": "main",
"index": 0
}
]
]
},
"Set": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {},
"versionId": "40a70df1-740f-47e7-8e16-50a0bcd5b70f",
"id": "998",
"meta": {
"instanceId": "95977dc4769098fc608439605527ee75d23f10d551aed6b87a3eea1a252c0ba9"
},
"tags": []
}
]
@@ -0,0 +1,81 @@
[
{
"name": "active-workflow updated",
"nodes": [
{
"parameters": {
"path": "e20b4873-fcf7-4bce-88fc-a1a56d66b138",
"responseMode": "responseNode",
"options": {}
},
"id": "c26d8782-bd57-43d0-86dc-0c618a7e4024",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [800, 580],
"webhookId": "e20b4873-fcf7-4bce-88fc-a1a56d66b138"
},
{
"parameters": {
"values": {
"boolean": [
{
"name": "hooked",
"value": true
}
]
},
"options": {}
},
"id": "9701b1ef-9ab0-432a-b086-cf76981b097d",
"name": "Set",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [1020, 580]
},
{
"parameters": {
"options": {}
},
"id": "d0f086b8-c2b2-4404-b347-95d3f91e555a",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [1240, 580]
}
],
"pinData": {},
"connections": {
"Webhook": {
"main": [
[
{
"node": "Set",
"type": "main",
"index": 0
}
]
]
},
"Set": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {},
"versionId": "40a70df1-740f-47e7-8e16-50a0bcd5b70f",
"id": "998",
"meta": {
"instanceId": "95977dc4769098fc608439605527ee75d23f10d551aed6b87a3eea1a252c0ba9"
},
"tags": []
}
]
@@ -0,0 +1,160 @@
[
{
"name": "active-workflow",
"nodes": [
{
"parameters": {
"path": "e20b4873-fcf7-4bce-88fc-a1a56d66b138",
"responseMode": "responseNode",
"options": {}
},
"id": "c26d8782-bd57-43d0-86dc-0c618a7e4024",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [800, 580],
"webhookId": "e20b4873-fcf7-4bce-88fc-a1a56d66b138"
},
{
"parameters": {
"values": {
"boolean": [
{
"name": "hooked",
"value": true
}
]
},
"options": {}
},
"id": "9701b1ef-9ab0-432a-b086-cf76981b097d",
"name": "Set",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [1020, 580]
},
{
"parameters": {
"options": {}
},
"id": "d0f086b8-c2b2-4404-b347-95d3f91e555a",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [1240, 580]
}
],
"pinData": {},
"connections": {
"Webhook": {
"main": [
[
{
"node": "Set",
"type": "main",
"index": 0
}
]
]
},
"Set": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {},
"versionId": "40a70df1-740f-47e7-8e16-50a0bcd5b70f",
"id": "998",
"meta": {
"instanceId": "95977dc4769098fc608439605527ee75d23f10d551aed6b87a3eea1a252c0ba9"
},
"tags": []
},
{
"name": "inactive-workflow",
"nodes": [
{
"parameters": {
"path": "e20b4873-fcf7-4bce-88fc-a1a56d66b137",
"responseMode": "responseNode",
"options": {}
},
"id": "c26d8782-bd57-43d0-86dc-0c618a7e4024",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [800, 580],
"webhookId": "e20b4873-fcf7-4bce-88fc-a1a56d66b137"
},
{
"parameters": {
"values": {
"boolean": [
{
"name": "hooked",
"value": true
}
]
},
"options": {}
},
"id": "9701b1ef-9ab0-432a-b086-cf76981b097c",
"name": "Set",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [1020, 580]
},
{
"parameters": {
"options": {}
},
"id": "d0f086b8-c2b2-4404-b347-95d3f91e555a",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [1240, 580]
}
],
"pinData": {},
"connections": {
"Webhook": {
"main": [
[
{
"node": "Set",
"type": "main",
"index": 0
}
]
]
},
"Set": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "40a70df1-740f-47e7-8e16-50a0bcd5b70f",
"id": "999",
"meta": {
"instanceId": "95977dc4769098fc608439605527ee75d23f10d551aed6b87a3eea1a252c0ba9"
},
"tags": []
}
]
@@ -0,0 +1,79 @@
{
"name": "active-workflow",
"nodes": [
{
"parameters": {
"path": "e20b4873-fcf7-4bce-88fc-a1a56d66b138",
"responseMode": "responseNode",
"options": {}
},
"id": "c26d8782-bd57-43d0-86dc-0c618a7e4024",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [800, 580],
"webhookId": "e20b4873-fcf7-4bce-88fc-a1a56d66b138"
},
{
"parameters": {
"values": {
"boolean": [
{
"name": "hooked",
"value": true
}
]
},
"options": {}
},
"id": "9701b1ef-9ab0-432a-b086-cf76981b097d",
"name": "Set",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [1020, 580]
},
{
"parameters": {
"options": {}
},
"id": "d0f086b8-c2b2-4404-b347-95d3f91e555a",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [1240, 580]
}
],
"pinData": {},
"connections": {
"Webhook": {
"main": [
[
{
"node": "Set",
"type": "main",
"index": 0
}
]
]
},
"Set": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {},
"versionId": "40a70df1-740f-47e7-8e16-50a0bcd5b70f",
"id": "998",
"meta": {
"instanceId": "95977dc4769098fc608439605527ee75d23f10d551aed6b87a3eea1a252c0ba9"
},
"tags": []
}
@@ -0,0 +1,79 @@
{
"name": "active-workflow",
"nodes": [
{
"parameters": {
"path": "e20b4873-fcf7-4bce-88fc-a1a56d66b138",
"responseMode": "responseNode",
"options": {}
},
"id": "c26d8782-bd57-43d0-86dc-0c618a7e4024",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [800, 580],
"webhookId": "e20b4873-fcf7-4bce-88fc-a1a56d66b138"
},
{
"parameters": {
"values": {
"boolean": [
{
"name": "hooked",
"value": true
}
]
},
"options": {}
},
"id": "9701b1ef-9ab0-432a-b086-cf76981b097d",
"name": "Set",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [1020, 580]
},
{
"parameters": {
"options": {}
},
"id": "d0f086b8-c2b2-4404-b347-95d3f91e555a",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [1240, 580]
}
],
"pinData": {},
"connections": {
"Webhook": {
"main": [
[
{
"node": "Set",
"type": "main",
"index": 0
}
]
]
},
"Set": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {},
"versionId": "40a70df1-740f-47e7-8e16-50a0bcd5b70f",
"id": "998",
"meta": {
"instanceId": "95977dc4769098fc608439605527ee75d23f10d551aed6b87a3eea1a252c0ba9"
},
"tags": []
}
@@ -0,0 +1,79 @@
{
"name": "inactive-workflow",
"nodes": [
{
"parameters": {
"path": "e20b4873-fcf7-4bce-88fc-a1a56d66b137",
"responseMode": "responseNode",
"options": {}
},
"id": "c26d8782-bd57-43d0-86dc-0c618a7e4024",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [800, 580],
"webhookId": "e20b4873-fcf7-4bce-88fc-a1a56d66b137"
},
{
"parameters": {
"values": {
"boolean": [
{
"name": "hooked",
"value": true
}
]
},
"options": {}
},
"id": "9701b1ef-9ab0-432a-b086-cf76981b097c",
"name": "Set",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [1020, 580]
},
{
"parameters": {
"options": {}
},
"id": "d0f086b8-c2b2-4404-b347-95d3f91e555a",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [1240, 580]
}
],
"pinData": {},
"connections": {
"Webhook": {
"main": [
[
{
"node": "Set",
"type": "main",
"index": 0
}
]
]
},
"Set": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {},
"versionId": "40a70df1-740f-47e7-8e16-50a0bcd5b70f",
"id": "999",
"meta": {
"instanceId": "95977dc4769098fc608439605527ee75d23f10d551aed6b87a3eea1a252c0ba9"
},
"tags": []
}
@@ -0,0 +1,24 @@
[
{
"id": "test-workflow-123",
"name": "Workflow with History Metadata",
"nodes": [
{
"parameters": {},
"id": "node-uuid-1",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [240, 300]
}
],
"connections": {},
"active": false,
"settings": {},
"versionId": "version-uuid-1",
"versionMetadata": {
"name": "Historical Version Name",
"description": "Historical version description"
}
}
]
@@ -0,0 +1,373 @@
import {
mockInstance,
testDb,
getPersonalProject,
getAllSharedWorkflows,
getAllWorkflows,
} from '@n8n/backend-test-utils';
import { WorkflowPublishHistoryRepository, WorkflowHistoryRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { nanoid } from 'nanoid';
import '@/zod-alias-support';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { ImportWorkflowsCommand } from '@/commands/import/workflow';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { setupTestCommand } from '@test-integration/utils/test-command';
import { createMember, createOwner } from '../shared/db/users';
mockInstance(LoadNodesAndCredentials);
mockInstance(ActiveWorkflowManager);
mockInstance(WorkflowPublishHistoryRepository);
const command = setupTestCommand(ImportWorkflowsCommand);
beforeEach(async () => {
await testDb.truncate(['WorkflowEntity', 'SharedWorkflow', 'User']);
});
test('import:workflow should import active workflow and deactivate it', async () => {
//
// ARRANGE
//
const owner = await createOwner();
const ownerProject = await getPersonalProject(owner);
//
// ACT
//
await command.run([
'--separate',
'--input=./test/integration/commands/import-workflows/separate',
]);
//
// ASSERT
//
const after = {
workflows: await getAllWorkflows(),
sharings: await getAllSharedWorkflows(),
};
expect(after).toMatchObject({
workflows: [
expect.objectContaining({ name: 'active-workflow', active: false, activeVersionId: null }),
expect.objectContaining({ name: 'inactive-workflow', active: false, activeVersionId: null }),
],
sharings: [
expect.objectContaining({
workflowId: '998',
projectId: ownerProject.id,
role: 'workflow:owner',
}),
expect.objectContaining({
workflowId: '999',
projectId: ownerProject.id,
role: 'workflow:owner',
}),
],
});
});
test('import:workflow should import active workflow from combined file and deactivate it', async () => {
//
// ARRANGE
//
const owner = await createOwner();
const ownerProject = await getPersonalProject(owner);
//
// ACT
//
await command.run([
'--input=./test/integration/commands/import-workflows/combined/combined.json',
]);
//
// ASSERT
//
const after = {
workflows: await getAllWorkflows(),
sharings: await getAllSharedWorkflows(),
};
expect(after).toMatchObject({
workflows: [
expect.objectContaining({ name: 'active-workflow', active: false, activeVersionId: null }),
expect.objectContaining({ name: 'inactive-workflow', active: false, activeVersionId: null }),
],
sharings: [
expect.objectContaining({
workflowId: '998',
projectId: ownerProject.id,
role: 'workflow:owner',
}),
expect.objectContaining({
workflowId: '999',
projectId: ownerProject.id,
role: 'workflow:owner',
}),
],
});
});
test('import:workflow can import a single workflow object', async () => {
//
// ARRANGE
//
const owner = await createOwner();
const ownerProject = await getPersonalProject(owner);
//
// ACT
//
await command.run(['--input=./test/integration/commands/import-workflows/combined/single.json']);
//
// ASSERT
//
const after = {
workflows: await getAllWorkflows(),
sharings: await getAllSharedWorkflows(),
};
expect(after).toMatchObject({
workflows: [
expect.objectContaining({ name: 'active-workflow', active: false, activeVersionId: null }),
],
sharings: [
expect.objectContaining({
workflowId: '998',
projectId: ownerProject.id,
role: 'workflow:owner',
}),
],
});
});
test('`import:workflow --userId ...` should fail if the workflow exists already and is owned by somebody else', async () => {
//
// ARRANGE
//
const owner = await createOwner();
const ownerProject = await getPersonalProject(owner);
const member = await createMember();
// Import workflow the first time, assigning it to a member.
await command.run([
'--input=./test/integration/commands/import-workflows/combined-with-update/original.json',
`--userId=${owner.id}`,
]);
const before = {
workflows: await getAllWorkflows(),
sharings: await getAllSharedWorkflows(),
};
// Make sure the workflow and sharing have been created.
expect(before).toMatchObject({
workflows: [expect.objectContaining({ id: '998', name: 'active-workflow' })],
sharings: [
expect.objectContaining({
workflowId: '998',
projectId: ownerProject.id,
role: 'workflow:owner',
}),
],
});
//
// ACT
//
// Import the same workflow again, with another name but the same ID, and try
// to assign it to the member.
await expect(
command.run([
'--input=./test/integration/commands/import-workflows/combined-with-update/updated.json',
`--userId=${member.id}`,
]),
).rejects.toThrowError(
`The credential with ID "998" is already owned by the user with the ID "${owner.id}". It can't be re-owned by the user with the ID "${member.id}"`,
);
//
// ASSERT
//
const after = {
workflows: await getAllWorkflows(),
sharings: await getAllSharedWorkflows(),
};
// Make sure there is no new sharing and that the name DID NOT change.
expect(after).toMatchObject({
workflows: [expect.objectContaining({ id: '998', name: 'active-workflow' })],
sharings: [
expect.objectContaining({
workflowId: '998',
projectId: ownerProject.id,
role: 'workflow:owner',
}),
],
});
});
test("only update the workflow, don't create or update the owner if `--userId` is not passed", async () => {
//
// ARRANGE
//
await createOwner();
const member = await createMember();
const memberProject = await getPersonalProject(member);
// Import workflow the first time, assigning it to a member.
await command.run([
'--input=./test/integration/commands/import-workflows/combined-with-update/original.json',
`--userId=${member.id}`,
]);
const before = {
workflows: await getAllWorkflows(),
sharings: await getAllSharedWorkflows(),
};
// Make sure the workflow and sharing have been created.
expect(before).toMatchObject({
workflows: [expect.objectContaining({ id: '998', name: 'active-workflow' })],
sharings: [
expect.objectContaining({
workflowId: '998',
projectId: memberProject.id,
role: 'workflow:owner',
}),
],
});
//
// ACT
//
// Import the same workflow again, with another name but the same ID.
await command.run([
'--input=./test/integration/commands/import-workflows/combined-with-update/updated.json',
]);
//
// ASSERT
//
const after = {
workflows: await getAllWorkflows(),
sharings: await getAllSharedWorkflows(),
};
// Make sure there is no new sharing and that the name changed.
expect(after).toMatchObject({
workflows: [expect.objectContaining({ id: '998', name: 'active-workflow updated' })],
sharings: [
expect.objectContaining({
workflowId: '998',
projectId: memberProject.id,
role: 'workflow:owner',
}),
],
});
});
test('`import:workflow --projectId ...` should fail if the credential already exists and is owned by another project', async () => {
//
// ARRANGE
//
const owner = await createOwner();
const ownerProject = await getPersonalProject(owner);
const member = await createMember();
const memberProject = await getPersonalProject(member);
// Import workflow the first time, assigning it to a member.
await command.run([
'--input=./test/integration/commands/import-workflows/combined-with-update/original.json',
`--userId=${owner.id}`,
]);
const before = {
workflows: await getAllWorkflows(),
sharings: await getAllSharedWorkflows(),
};
// Make sure the workflow and sharing have been created.
expect(before).toMatchObject({
workflows: [expect.objectContaining({ id: '998', name: 'active-workflow' })],
sharings: [
expect.objectContaining({
workflowId: '998',
projectId: ownerProject.id,
role: 'workflow:owner',
}),
],
});
//
// ACT
//
// Import the same workflow again, with another name but the same ID, and try
// to assign it to the member.
await expect(
command.run([
'--input=./test/integration/commands/import-workflows/combined-with-update/updated.json',
`--projectId=${memberProject.id}`,
]),
).rejects.toThrowError(
`The credential with ID "998" is already owned by the user with the ID "${owner.id}". It can't be re-owned by the project with the ID "${memberProject.id}"`,
);
//
// ASSERT
//
const after = {
workflows: await getAllWorkflows(),
sharings: await getAllSharedWorkflows(),
};
// Make sure there is no new sharing and that the name DID NOT change.
expect(after).toMatchObject({
workflows: [expect.objectContaining({ id: '998', name: 'active-workflow' })],
sharings: [
expect.objectContaining({
workflowId: '998',
projectId: ownerProject.id,
role: 'workflow:owner',
}),
],
});
});
test('`import:workflow --projectId ... --userId ...` fails explaining that only one of the options can be used at a time', async () => {
await expect(
command.run([
'--input=./test/integration/commands/import-workflows/combined-with-update/updated.json',
`--userId=${nanoid()}`,
`--projectId=${nanoid()}`,
]),
).rejects.toThrowError(
'You cannot use `--userId` and `--projectId` together. Use one or the other.',
);
});
test('should preserve versionMetadata from JSON file when importing', async () => {
//
// ARRANGE
//
await createOwner();
//
// ACT
//
await command.run([
'--input=./test/integration/commands/import-workflows/with-history/workflow-with-metadata.json',
]);
//
// ASSERT
//
const workflows = await getAllWorkflows();
expect(workflows).toHaveLength(1);
expect(workflows[0].id).toBe('test-workflow-123');
expect(workflows[0].name).toBe('Workflow with History Metadata');
const workflowHistoryRecords = await Container.get(WorkflowHistoryRepository).find({
where: { workflowId: 'test-workflow-123' },
});
expect(workflowHistoryRecords).toHaveLength(1);
expect(workflowHistoryRecords[0].name).toBe('Historical Version Name');
expect(workflowHistoryRecords[0].description).toBe('Historical version description');
});
@@ -0,0 +1,365 @@
import {
createTeamProject,
findProject,
getPersonalProject,
mockInstance,
createWorkflow,
randomCredentialPayload,
} from '@n8n/backend-test-utils';
import {
CredentialsRepository,
SharedCredentialsRepository,
SharedWorkflowRepository,
WorkflowRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { EntityNotFoundError } from '@n8n/typeorm';
import { v4 as uuid } from 'uuid';
import { Reset } from '@/commands/ldap/reset';
import { getLdapSynchronizations, saveLdapSynchronization } from '@/modules/ldap.ee/helpers.ee';
import { LdapService } from '@/modules/ldap.ee/ldap.service.ee';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { Push } from '@/push';
import { Telemetry } from '@/telemetry';
import { setupTestCommand } from '@test-integration/utils/test-command';
import { saveCredential } from '../../shared/db/credentials';
import { createLdapUser, createMember, getUserById } from '../../shared/db/users';
import { createLdapConfig } from '../../shared/ldap';
mockInstance(Telemetry);
mockInstance(Push);
mockInstance(LoadNodesAndCredentials);
const command = setupTestCommand(Reset);
test('fails if neither `--userId` nor `--projectId` nor `--deleteWorkflowsAndCredentials` is passed', async () => {
await expect(command.run()).rejects.toThrowError(
'You must use exactly one of `--userId`, `--projectId` or `--deleteWorkflowsAndCredentials`.',
);
});
test.each([
[`--userId=${uuid()}`, `--projectId=${uuid()}`, '--deleteWorkflowsAndCredentials'],
[`--userId=${uuid()}`, `--projectId=${uuid()}`],
[`--userId=${uuid()}`, '--deleteWorkflowsAndCredentials'],
['--deleteWorkflowsAndCredentials', `--projectId=${uuid()}`],
])(
'fails if more than one of `--userId`, `--projectId`, `--deleteWorkflowsAndCredentials` are passed',
async (...argv) => {
await expect(command.run(argv)).rejects.toThrowError(
'You must use exactly one of `--userId`, `--projectId` or `--deleteWorkflowsAndCredentials`.',
);
},
);
describe('--deleteWorkflowsAndCredentials', () => {
test('deletes personal projects, workflows and credentials owned by LDAP managed users', async () => {
//
// ARRANGE
//
const member = await createLdapUser({ role: { slug: 'global:member' } }, uuid());
const memberProject = await getPersonalProject(member);
const workflow = await createWorkflow({}, member);
const credential = await saveCredential(randomCredentialPayload(), {
user: member,
role: 'credential:owner',
});
const normalMember = await createMember();
const workflow2 = await createWorkflow({}, normalMember);
const credential2 = await saveCredential(randomCredentialPayload(), {
user: normalMember,
role: 'credential:owner',
});
//
// ACT
//
await command.run(['--deleteWorkflowsAndCredentials']);
//
// ASSERT
//
// LDAP user is deleted
await expect(getUserById(member.id)).rejects.toThrowError(EntityNotFoundError);
await expect(findProject(memberProject.id)).rejects.toThrowError(EntityNotFoundError);
await expect(
Container.get(WorkflowRepository).findOneBy({ id: workflow.id }),
).resolves.toBeNull();
await expect(
Container.get(CredentialsRepository).findOneBy({ id: credential.id }),
).resolves.toBeNull();
// Non LDAP user is not deleted
await expect(getUserById(normalMember.id)).resolves.not.toThrowError();
await expect(
Container.get(WorkflowRepository).findOneBy({ id: workflow2.id }),
).resolves.not.toBeNull();
await expect(
Container.get(CredentialsRepository).findOneBy({ id: credential2.id }),
).resolves.not.toBeNull();
});
test('deletes the LDAP sync history', async () => {
//
// ARRANGE
//
await saveLdapSynchronization({
created: 1,
disabled: 1,
scanned: 1,
updated: 1,
endedAt: new Date(),
startedAt: new Date(),
error: '',
runMode: 'dry',
status: 'success',
});
//
// ACT
//
await command.run(['--deleteWorkflowsAndCredentials']);
//
// ASSERT
//
await expect(getLdapSynchronizations(0, 10)).resolves.toHaveLength(0);
});
test('resets LDAP settings', async () => {
//
// ARRANGE
//
await createLdapConfig();
await expect(Container.get(LdapService).loadConfig()).resolves.toMatchObject({
loginEnabled: true,
});
//
// ACT
//
await command.run(['--deleteWorkflowsAndCredentials']);
//
// ASSERT
//
await expect(Container.get(LdapService).loadConfig()).resolves.toMatchObject({
loginEnabled: false,
});
});
});
describe('--userId', () => {
test('fails if the user does not exist', async () => {
const userId = uuid();
await expect(command.run([`--userId=${userId}`])).rejects.toThrowError(
`Could not find the user with the ID ${userId} or their personalProject.`,
);
});
test('fails if the user to migrate to is also an LDAP user', async () => {
//
// ARRANGE
//
const member = await createLdapUser({ role: { slug: 'global:member' } }, uuid());
await expect(command.run([`--userId=${member.id}`])).rejects.toThrowError(
`Can't migrate workflows and credentials to the user with the ID ${member.id}. That user was created via LDAP and will be deleted as well.`,
);
});
test("transfers all workflows and credentials to the user's personal project", async () => {
//
// ARRANGE
//
const member = await createLdapUser({ role: { slug: 'global:member' } }, uuid());
const memberProject = await getPersonalProject(member);
const workflow = await createWorkflow({}, member);
const credential = await saveCredential(randomCredentialPayload(), {
user: member,
role: 'credential:owner',
});
const normalMember = await createMember();
const normalMemberProject = await getPersonalProject(normalMember);
const workflow2 = await createWorkflow({}, normalMember);
const credential2 = await saveCredential(randomCredentialPayload(), {
user: normalMember,
role: 'credential:owner',
});
//
// ACT
//
await command.run([`--userId=${normalMember.id}`]);
//
// ASSERT
//
// LDAP user is deleted
await expect(getUserById(member.id)).rejects.toThrowError(EntityNotFoundError);
await expect(findProject(memberProject.id)).rejects.toThrowError(EntityNotFoundError);
// Their workflow and credential have been migrated to the normal user.
await expect(
Container.get(SharedWorkflowRepository).findOneBy({
workflowId: workflow.id,
projectId: normalMemberProject.id,
}),
).resolves.not.toBeNull();
await expect(
Container.get(SharedCredentialsRepository).findOneBy({
credentialsId: credential.id,
projectId: normalMemberProject.id,
}),
).resolves.not.toBeNull();
// Non LDAP user is not deleted
await expect(getUserById(normalMember.id)).resolves.not.toThrowError();
await expect(
Container.get(WorkflowRepository).findOneBy({ id: workflow2.id }),
).resolves.not.toBeNull();
await expect(
Container.get(CredentialsRepository).findOneBy({ id: credential2.id }),
).resolves.not.toBeNull();
});
});
describe('--projectId', () => {
test('fails if the project does not exist', async () => {
const projectId = uuid();
await expect(command.run([`--projectId=${projectId}`])).rejects.toThrowError(
`Could not find the project with the ID ${projectId}.`,
);
});
test('fails if the user to migrate to is also an LDAP user', async () => {
//
// ARRANGE
//
const member = await createLdapUser({ role: { slug: 'global:member' } }, uuid());
const memberProject = await getPersonalProject(member);
await expect(command.run([`--projectId=${memberProject.id}`])).rejects.toThrowError(
`Can't migrate workflows and credentials to the project with the ID ${memberProject.id}. That project is a personal project belonging to a user that was created via LDAP and will be deleted as well.`,
);
});
test('transfers all workflows and credentials to a personal project', async () => {
//
// ARRANGE
//
const member = await createLdapUser({ role: { slug: 'global:member' } }, uuid());
const memberProject = await getPersonalProject(member);
const workflow = await createWorkflow({}, member);
const credential = await saveCredential(randomCredentialPayload(), {
user: member,
role: 'credential:owner',
});
const normalMember = await createMember();
const normalMemberProject = await getPersonalProject(normalMember);
const workflow2 = await createWorkflow({}, normalMember);
const credential2 = await saveCredential(randomCredentialPayload(), {
user: normalMember,
role: 'credential:owner',
});
//
// ACT
//
await command.run([`--projectId=${normalMemberProject.id}`]);
//
// ASSERT
//
// LDAP user is deleted
await expect(getUserById(member.id)).rejects.toThrowError(EntityNotFoundError);
await expect(findProject(memberProject.id)).rejects.toThrowError(EntityNotFoundError);
// Their workflow and credential have been migrated to the normal user.
await expect(
Container.get(SharedWorkflowRepository).findOneBy({
workflowId: workflow.id,
projectId: normalMemberProject.id,
}),
).resolves.not.toBeNull();
await expect(
Container.get(SharedCredentialsRepository).findOneBy({
credentialsId: credential.id,
projectId: normalMemberProject.id,
}),
).resolves.not.toBeNull();
// Non LDAP user is not deleted
await expect(getUserById(normalMember.id)).resolves.not.toThrowError();
await expect(
Container.get(WorkflowRepository).findOneBy({ id: workflow2.id }),
).resolves.not.toBeNull();
await expect(
Container.get(CredentialsRepository).findOneBy({ id: credential2.id }),
).resolves.not.toBeNull();
});
test('transfers all workflows and credentials to a team project', async () => {
//
// ARRANGE
//
const member = await createLdapUser({ role: { slug: 'global:member' } }, uuid());
const memberProject = await getPersonalProject(member);
const workflow = await createWorkflow({}, member);
const credential = await saveCredential(randomCredentialPayload(), {
user: member,
role: 'credential:owner',
});
const normalMember = await createMember();
const workflow2 = await createWorkflow({}, normalMember);
const credential2 = await saveCredential(randomCredentialPayload(), {
user: normalMember,
role: 'credential:owner',
});
const teamProject = await createTeamProject();
//
// ACT
//
await command.run([`--projectId=${teamProject.id}`]);
//
// ASSERT
//
// LDAP user is deleted
await expect(getUserById(member.id)).rejects.toThrowError(EntityNotFoundError);
await expect(findProject(memberProject.id)).rejects.toThrowError(EntityNotFoundError);
// Their workflow and credential have been migrated to the team project.
await expect(
Container.get(SharedWorkflowRepository).findOneBy({
workflowId: workflow.id,
projectId: teamProject.id,
}),
).resolves.not.toBeNull();
await expect(
Container.get(SharedCredentialsRepository).findOneBy({
credentialsId: credential.id,
projectId: teamProject.id,
}),
).resolves.not.toBeNull();
// Non LDAP user is not deleted
await expect(getUserById(normalMember.id)).resolves.not.toThrowError();
await expect(
Container.get(WorkflowRepository).findOneBy({ id: workflow2.id }),
).resolves.not.toBeNull();
await expect(
Container.get(CredentialsRepository).findOneBy({ id: credential2.id }),
).resolves.not.toBeNull();
});
});
@@ -0,0 +1,36 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { Container } from '@n8n/di';
import { ClearLicenseCommand } from '@/commands/license/clear';
import { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { setupTestCommand } from '@test-integration/utils/test-command';
mockInstance(LoadNodesAndCredentials);
const command = setupTestCommand(ClearLicenseCommand);
test('license:clear invokes clear() to release any floating entitlements and deletes the license cert from the DB', async () => {
const license = Container.get(License);
const manager = {
clear: jest.fn().mockImplementation(async () => {
await license.saveCertStr('');
}),
};
const initSpy = jest.spyOn(license, 'init').mockImplementation(async () => {
Object.defineProperty(license, 'manager', {
value: manager,
writable: true,
});
});
const clearSpy = jest.spyOn(license, 'clear');
const saveCertStrSpy = jest.spyOn(license, 'saveCertStr');
await command.run();
expect(initSpy).toHaveBeenCalledTimes(1);
expect(clearSpy).toHaveBeenCalledTimes(1);
expect(saveCertStrSpy).toHaveBeenCalledWith('');
});
@@ -0,0 +1,111 @@
import {
mockInstance,
testDb,
createWorkflowWithTriggerAndHistory,
getWorkflowById,
} from '@n8n/backend-test-utils';
import { PublishWorkflowCommand } from '@/commands/publish/workflow';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { setupTestCommand } from '@test-integration/utils/test-command';
mockInstance(LoadNodesAndCredentials);
const command = setupTestCommand(PublishWorkflowCommand);
beforeEach(async () => {
await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']);
});
test('publish:workflow can publish a specific workflow version', async () => {
//
// ARRANGE
//
const workflow = await createWorkflowWithTriggerAndHistory();
//
// ACT
//
await command.run([`--id=${workflow.id}`, `--versionId=${workflow.versionId}`]);
//
// ASSERT
//
const updatedWorkflow = await getWorkflowById(workflow.id);
expect(updatedWorkflow).toMatchObject({
activeVersionId: workflow.versionId,
active: true,
});
});
test('publish:workflow does not publish when --all flag is used', async () => {
//
// ARRANGE
//
const workflow = await createWorkflowWithTriggerAndHistory();
//
// ACT
//
await command.run(['--all', `--id=${workflow.id}`, `--versionId=${workflow.versionId}`]);
//
// ASSERT
//
// Verify the workflow was not published (--all flag prevents publishing)
const unchangedWorkflow = await getWorkflowById(workflow.id);
expect(unchangedWorkflow).toMatchObject({
activeVersionId: null,
active: false,
});
});
test('publish:workflow publishes current version when --versionId is missing', async () => {
//
// ARRANGE
//
const workflow = await createWorkflowWithTriggerAndHistory();
//
// ACT
//
await command.run([`--id=${workflow.id}`]);
//
// ASSERT
//
const updatedWorkflow = await getWorkflowById(workflow.id);
expect(updatedWorkflow).toMatchObject({
activeVersionId: workflow.versionId,
active: true,
});
});
test('unpublish:workflow throws error when workflow does not exist', async () => {
//
// ARRANGE
//
const nonExistentWorkflowId = 'non-existent-workflow-id';
//
// ACT & ASSERT
//
await expect(command.run([`--id=${nonExistentWorkflowId}`])).rejects.toThrow(
`Workflow "${nonExistentWorkflowId}" not found.`,
);
});
test('publish:workflow throws error when version does not exist', async () => {
//
// ARRANGE
//
const workflow = await createWorkflowWithTriggerAndHistory();
const nonExistentVersionId = 'non-existent-version';
//
// ACT & ASSERT
//
await expect(
command.run([`--id=${workflow.id}`, `--versionId=${nonExistentVersionId}`]),
).rejects.toThrow(`Version "${nonExistentVersionId}" not found for workflow "${workflow.id}".`);
});
@@ -0,0 +1,96 @@
import {
getPersonalProject,
mockInstance,
createWorkflow,
testDb,
randomCredentialPayload,
} from '@n8n/backend-test-utils';
import {
CredentialsEntity,
CredentialsRepository,
SharedCredentialsRepository,
SharedWorkflowRepository,
UserRepository,
GLOBAL_OWNER_ROLE,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { Reset } from '@/commands/user-management/reset';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { NodeTypes } from '@/node-types';
import { setupTestCommand } from '@test-integration/utils/test-command';
import { encryptCredentialData, saveCredential } from '../shared/db/credentials';
import { createMember, createUser } from '../shared/db/users';
mockInstance(LoadNodesAndCredentials);
mockInstance(NodeTypes);
const command = setupTestCommand(Reset);
beforeEach(async () => {
await testDb.truncate(['User']);
});
test('user-management:reset should reset DB to default user state', async () => {
//
// ARRANGE
//
const owner = await createUser({ role: GLOBAL_OWNER_ROLE });
const ownerProject = await getPersonalProject(owner);
// should be deleted
const member = await createMember();
// should be re-owned
const workflow = await createWorkflow({}, member);
const credential = await saveCredential(randomCredentialPayload(), {
user: member,
role: 'credential:owner',
});
// dangling credentials should also be re-owned
const danglingCredential = await Container.get(CredentialsRepository).save(
await encryptCredentialData(Object.assign(new CredentialsEntity(), randomCredentialPayload())),
);
//
// ACT
//
await command.run();
//
// ASSERT
//
// check if the owner account was reset:
await expect(
Container.get(UserRepository).findOneBy({ role: { slug: GLOBAL_OWNER_ROLE.slug } }),
).resolves.toMatchObject({
email: null,
firstName: null,
lastName: null,
password: null,
personalizationAnswers: null,
});
// all members were deleted:
const members = await Container.get(UserRepository).findOneBy({
role: { slug: 'global:member' },
});
expect(members).toBeNull();
// all workflows are owned by the owner:
await expect(
Container.get(SharedWorkflowRepository).findBy({ workflowId: workflow.id }),
).resolves.toMatchObject([{ projectId: ownerProject.id, role: 'workflow:owner' }]);
// all credentials are owned by the owner
await expect(
Container.get(SharedCredentialsRepository).findBy({ credentialsId: credential.id }),
).resolves.toMatchObject([{ projectId: ownerProject.id, role: 'credential:owner' }]);
// all dangling credentials are owned by the owner
await expect(
Container.get(SharedCredentialsRepository).findBy({ credentialsId: danglingCredential.id }),
).resolves.toMatchObject([{ projectId: ownerProject.id, role: 'credential:owner' }]);
});
@@ -0,0 +1,144 @@
import {
mockInstance,
testDb,
createManyActiveWorkflows,
getWorkflowById,
} from '@n8n/backend-test-utils';
import { UnpublishWorkflowCommand } from '@/commands/unpublish/workflow';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { setupTestCommand } from '@test-integration/utils/test-command';
mockInstance(LoadNodesAndCredentials);
const command = setupTestCommand(UnpublishWorkflowCommand);
beforeEach(async () => {
await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']);
});
test('unpublish:workflow can unpublish all workflows', async () => {
//
// ARRANGE
//
const workflows = await createManyActiveWorkflows(2);
//
// ACT
//
await command.run(['--all']);
//
// ASSERT
//
const workflow1 = await getWorkflowById(workflows[0].id);
const workflow2 = await getWorkflowById(workflows[1].id);
expect(workflow1).toMatchObject({
activeVersionId: null,
active: false,
});
expect(workflow2).toMatchObject({
activeVersionId: null,
active: false,
});
});
test('unpublish:workflow can unpublish a specific workflow', async () => {
//
// ARRANGE
//
const workflows = await createManyActiveWorkflows(2);
//
// ACT
//
await command.run([`--id=${workflows[0].id}`]);
//
// ASSERT
//
const unpublishedWorkflow = await getWorkflowById(workflows[0].id);
const activeWorkflow = await getWorkflowById(workflows[1].id);
expect(unpublishedWorkflow).toMatchObject({
activeVersionId: null,
active: false,
});
expect(activeWorkflow).toMatchObject({
activeVersionId: workflows[1].versionId,
active: true,
});
});
test('unpublish:workflow does nothing when neither --all nor --id is provided', async () => {
//
// ARRANGE
//
const workflows = await createManyActiveWorkflows(2);
//
// ACT
//
await command.run([]);
//
// ASSERT
//
// Verify workflows are still active
const workflow1 = await getWorkflowById(workflows[0].id);
const workflow2 = await getWorkflowById(workflows[1].id);
expect(workflow1).toMatchObject({
activeVersionId: workflows[0].versionId,
active: true,
});
expect(workflow2).toMatchObject({
activeVersionId: workflows[1].versionId,
active: true,
});
});
test('unpublish:workflow does nothing when both --all and --id are provided', async () => {
//
// ARRANGE
//
const workflows = await createManyActiveWorkflows(2);
//
// ACT
//
await command.run(['--all', '--id=123']);
//
// ASSERT
//
// Verify workflows are still active
const workflow1 = await getWorkflowById(workflows[0].id);
const workflow2 = await getWorkflowById(workflows[1].id);
expect(workflow1).toMatchObject({
activeVersionId: workflows[0].versionId,
active: true,
});
expect(workflow2).toMatchObject({
activeVersionId: workflows[1].versionId,
active: true,
});
});
test('unpublish:workflow throws error when workflow does not exist', async () => {
//
// ARRANGE
//
const nonExistentWorkflowId = 'non-existent-workflow-id';
//
// ACT & ASSERT
//
await expect(command.run([`--id=${nonExistentWorkflowId}`])).rejects.toThrow(
`Workflow "${nonExistentWorkflowId}" not found.`,
);
});
@@ -0,0 +1,128 @@
import {
mockInstance,
testDb,
createWorkflowWithTriggerAndHistory,
createManyActiveWorkflows,
getAllWorkflows,
} from '@n8n/backend-test-utils';
import { WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { UpdateWorkflowCommand } from '@/commands/update/workflow';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { setupTestCommand } from '@test-integration/utils/test-command';
mockInstance(LoadNodesAndCredentials);
const command = setupTestCommand(UpdateWorkflowCommand);
beforeEach(async () => {
await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']);
});
test('update:workflow does not publish when trying to publish all workflows', async () => {
//
// ARRANGE
//
const workflows = await Promise.all([
createWorkflowWithTriggerAndHistory({}),
createWorkflowWithTriggerAndHistory({}),
]);
//
// ACT
//
await command.run(['--all', '--active=true']);
//
// ASSERT
//
// Verify workflows were NOT published (publishing all is no longer supported)
const workflowRepo = Container.get(WorkflowRepository);
const workflow1 = await workflowRepo.findOneBy({ id: workflows[0].id });
const workflow2 = await workflowRepo.findOneBy({ id: workflows[1].id });
expect(workflow1?.activeVersionId).toBeNull();
expect(workflow2?.activeVersionId).toBeNull();
});
test('update:workflow can unpublish all workflows', async () => {
//
// ARRANGE
//
const workflows = await createManyActiveWorkflows(2);
// Verify activeVersionId is set
const workflowRepo = Container.get(WorkflowRepository);
let workflow1 = await workflowRepo.findOneBy({ id: workflows[0].id });
let workflow2 = await workflowRepo.findOneBy({ id: workflows[1].id });
expect(workflow1?.activeVersionId).toBe(workflows[0].versionId);
expect(workflow2?.activeVersionId).toBe(workflows[1].versionId);
//
// ACT
//
await command.run(['--all', '--active=false']);
//
// ASSERT
//
// Verify activeVersionId is cleared
workflow1 = await workflowRepo.findOne({
where: { id: workflows[0].id },
relations: ['activeVersion'],
});
workflow2 = await workflowRepo.findOne({
where: { id: workflows[1].id },
relations: ['activeVersion'],
});
expect(workflow1?.activeVersionId).toBeNull();
expect(workflow1?.activeVersion).toBeNull();
expect(workflow2?.activeVersionId).toBeNull();
expect(workflow2?.activeVersion).toBeNull();
});
test('update:workflow publishes current version when --active=true (backwards compatibility)', async () => {
//
// ARRANGE
//
const workflow = await createWorkflowWithTriggerAndHistory();
//
// ACT
//
await command.run([`--id=${workflow.id}`, '--active=true']);
//
// ASSERT
//
// Verify workflow was published with current version
const workflowRepo = Container.get(WorkflowRepository);
const updatedWorkflow = await workflowRepo.findOneBy({ id: workflow.id });
expect(updatedWorkflow?.activeVersionId).toBe(workflow.versionId);
expect(updatedWorkflow?.active).toBe(true);
});
test('update:workflow can unpublish a specific workflow', async () => {
//
// ARRANGE
//
const workflows = (await createManyActiveWorkflows(2)).sort((wf1, wf2) =>
wf1.id.localeCompare(wf2.id),
);
//
// ACT
//
await command.run([`--id=${workflows[0].id}`, '--active=false']);
//
// ASSERT
//
const after = (await getAllWorkflows()).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id));
expect(after).toMatchObject([
{ activeVersionId: null },
{ activeVersionId: workflows[1].versionId },
]);
});
@@ -0,0 +1,64 @@
process.argv[2] = 'worker';
import { mockInstance } from '@n8n/backend-test-utils';
import { ExecutionsConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { BinaryDataService } from 'n8n-core';
import { Worker } from '@/commands/worker';
import config from '@/config';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay';
import { ExternalHooks } from '@/external-hooks';
import { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { CommunityPackagesService } from '@/modules/community-packages/community-packages.service';
import { Push } from '@/push';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
import { ScalingService } from '@/scaling/scaling.service';
import { TaskBrokerServer } from '@/task-runners/task-broker/task-broker-server';
import { JsTaskRunnerProcess } from '@/task-runners/task-runner-process-js';
import { PyTaskRunnerProcess } from '@/task-runners/task-runner-process-py';
import { Telemetry } from '@/telemetry';
import { setupTestCommand } from '@test-integration/utils/test-command';
Container.get(ExecutionsConfig).mode = 'queue';
config.set('binaryDataManager.availableModes', 'filesystem');
mockInstance(LoadNodesAndCredentials);
const binaryDataService = mockInstance(BinaryDataService);
const communityPackagesService = mockInstance(CommunityPackagesService);
const externalHooks = mockInstance(ExternalHooks);
const license = mockInstance(License, { loadCertStr: async () => '' });
const messageEventBus = mockInstance(MessageEventBus);
const logStreamingEventRelay = mockInstance(LogStreamingEventRelay);
const scalingService = mockInstance(ScalingService);
const taskBrokerServer = mockInstance(TaskBrokerServer);
const taskRunnerProcess = mockInstance(JsTaskRunnerProcess);
mockInstance(PyTaskRunnerProcess);
mockInstance(Publisher);
mockInstance(Subscriber);
mockInstance(Telemetry);
mockInstance(Push);
const command = setupTestCommand(Worker);
test('worker initializes all its components', async () => {
Container.get(ExecutionsConfig).mode = 'regular'; // should be overridden
await command.run();
expect(license.init).toHaveBeenCalledTimes(1);
expect(binaryDataService.init).toHaveBeenCalledTimes(1);
expect(communityPackagesService.init).toHaveBeenCalledTimes(1);
expect(externalHooks.init).toHaveBeenCalledTimes(1);
expect(messageEventBus.initialize).toHaveBeenCalledTimes(1);
expect(scalingService.setupQueue).toHaveBeenCalledTimes(1);
expect(scalingService.setupWorker).toHaveBeenCalledTimes(1);
expect(logStreamingEventRelay.init).toHaveBeenCalledTimes(1);
expect(messageEventBus.send).toHaveBeenCalledTimes(1);
expect(taskBrokerServer.start).toHaveBeenCalledTimes(1);
expect(taskRunnerProcess.start).toHaveBeenCalledTimes(1);
expect(Container.get(ExecutionsConfig).mode).toBe('queue');
});