mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(API): Add partial import of a selected workflow subset (no-changelog) (#39535)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8e065f9a78
commit
893e97c18e
|
|
@ -431,6 +431,8 @@ export { ImportCsvToDataTableDto } from './data-table/import-csv-to-data-table.d
|
|||
export {
|
||||
ImportPackageRequestDto,
|
||||
IMPORT_PACKAGE_REQUEST_FORM_FIELDS,
|
||||
ImportPackageSelectionRequestDto,
|
||||
IMPORT_PACKAGE_SELECTION_REQUEST_FORM_FIELDS,
|
||||
} from './packages/import-package-request.dto';
|
||||
export { ExportPackageRequestDto } from './packages/export-package-request.dto';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
import {
|
||||
ImportPackageSelectionRequestDto,
|
||||
IMPORT_PACKAGE_SELECTION_REQUEST_FORM_FIELDS,
|
||||
} from '../import-package-request.dto';
|
||||
|
||||
describe('ImportPackageSelectionRequestDto', () => {
|
||||
const base = {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: '["WFA","WFB"]',
|
||||
};
|
||||
|
||||
it('parses selectedWorkflowIds from a JSON array string and defaults the overridable policies', () => {
|
||||
const result = ImportPackageSelectionRequestDto.safeParse(base);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual({
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA', 'WFB'],
|
||||
deletedWorkflowIds: undefined,
|
||||
workflowConflictPolicy: 'new-version',
|
||||
workflowIdPolicy: 'source',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('parses deletedWorkflowIds when present', () => {
|
||||
const result = ImportPackageSelectionRequestDto.safeParse({
|
||||
...base,
|
||||
deletedWorkflowIds: '["WFC"]',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toEqual({
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA', 'WFB'],
|
||||
deletedWorkflowIds: ['WFC'],
|
||||
workflowConflictPolicy: 'new-version',
|
||||
workflowIdPolicy: 'source',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts an empty selectedWorkflowIds array', () => {
|
||||
const result = ImportPackageSelectionRequestDto.safeParse({
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: '[]',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.selectedWorkflowIds).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves deletedWorkflowIds undefined when blank', () => {
|
||||
const result = ImportPackageSelectionRequestDto.safeParse({
|
||||
...base,
|
||||
deletedWorkflowIds: ' ',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.deletedWorkflowIds).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'missing (blank)', selectedWorkflowIds: '' },
|
||||
{ name: 'invalid JSON', selectedWorkflowIds: 'not json' },
|
||||
{ name: 'a JSON object rather than array', selectedWorkflowIds: '{"a":"b"}' },
|
||||
{ name: 'a non-string element', selectedWorkflowIds: '["WFA",1]' },
|
||||
{ name: 'an empty-string element', selectedWorkflowIds: '["WFA",""]' },
|
||||
{ name: 'whitespace-only (blank)', selectedWorkflowIds: ' ' },
|
||||
{ name: 'a whitespace-only element', selectedWorkflowIds: '["WFA"," "]' },
|
||||
])('rejects selectedWorkflowIds that is $name', ({ selectedWorkflowIds }) => {
|
||||
expect(
|
||||
ImportPackageSelectionRequestDto.safeParse({ selectedProjectId: 'P1', selectedWorkflowIds })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'invalid JSON', deletedWorkflowIds: 'not json' },
|
||||
{ name: 'a non-string element', deletedWorkflowIds: '[1]' },
|
||||
{ name: 'a whitespace-only element', deletedWorkflowIds: '[" "]' },
|
||||
])('rejects deletedWorkflowIds that is $name', ({ deletedWorkflowIds }) => {
|
||||
expect(
|
||||
ImportPackageSelectionRequestDto.safeParse({ ...base, deletedWorkflowIds }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'absent', request: { selectedWorkflowIds: '["WFA"]' } },
|
||||
{ name: 'empty', request: { selectedProjectId: '', selectedWorkflowIds: '["WFA"]' } },
|
||||
{
|
||||
name: 'whitespace-only',
|
||||
request: { selectedProjectId: ' ', selectedWorkflowIds: '["WFA"]' },
|
||||
},
|
||||
])('rejects a $name selectedProjectId', ({ request }) => {
|
||||
expect(ImportPackageSelectionRequestDto.safeParse(request).success).toBe(false);
|
||||
});
|
||||
|
||||
describe('overridable policy enums', () => {
|
||||
it('defaults blank policy fields', () => {
|
||||
const result = ImportPackageSelectionRequestDto.safeParse({
|
||||
...base,
|
||||
workflowConflictPolicy: '',
|
||||
workflowIdPolicy: ' ',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.workflowConflictPolicy).toBe('new-version');
|
||||
expect(result.data.workflowIdPolicy).toBe('source');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts explicit values', () => {
|
||||
const result = ImportPackageSelectionRequestDto.safeParse({
|
||||
...base,
|
||||
workflowConflictPolicy: 'skip',
|
||||
workflowIdPolicy: 'new',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.workflowConflictPolicy).toBe('skip');
|
||||
expect(result.data.workflowIdPolicy).toBe('new');
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ field: 'workflowConflictPolicy', value: 'overwrite' },
|
||||
{ field: 'workflowIdPolicy', value: 'reuse' },
|
||||
])('rejects an unsupported $field value', ({ field, value }) => {
|
||||
expect(ImportPackageSelectionRequestDto.safeParse({ ...base, [field]: value }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not accept a target projectId/folderId, the locked cherry-pick policies, nor bindings', () => {
|
||||
const result = ImportPackageSelectionRequestDto.safeParse({
|
||||
...base,
|
||||
projectId: 'proj-1',
|
||||
folderId: 'fld-1',
|
||||
folderConflictPolicy: 'overwrite',
|
||||
tagConflictPolicy: 'fail',
|
||||
projectConflictPolicy: 'overwrite',
|
||||
overwriteDeletionPolicy: 'hard-delete',
|
||||
bindings: '{"credentials":{"a":"b"}}',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
// The DTO strips unknown fields instead of rejecting the request.
|
||||
expect(result.data).not.toHaveProperty('projectId');
|
||||
expect(result.data).not.toHaveProperty('folderId');
|
||||
expect(result.data).not.toHaveProperty('folderConflictPolicy');
|
||||
expect(result.data).not.toHaveProperty('tagConflictPolicy');
|
||||
expect(result.data).not.toHaveProperty('projectConflictPolicy');
|
||||
expect(result.data).not.toHaveProperty('overwriteDeletionPolicy');
|
||||
expect(result.data).not.toHaveProperty('bindings');
|
||||
}
|
||||
});
|
||||
|
||||
it('lists the selection fields as multipart form fields', () => {
|
||||
expect(IMPORT_PACKAGE_SELECTION_REQUEST_FORM_FIELDS).toEqual([
|
||||
'selectedProjectId',
|
||||
'selectedWorkflowIds',
|
||||
'deletedWorkflowIds',
|
||||
'workflowConflictPolicy',
|
||||
'workflowIdPolicy',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -118,3 +118,71 @@ export class ImportPackageRequestDto extends Z.class({
|
|||
tagMissingMode: optionalEnum(['create', 'do-nothing'], 'create'),
|
||||
tagConflictPolicy: optionalEnum(['skip', 'fail', 'rename'], 'skip'),
|
||||
}) {}
|
||||
|
||||
/** Multipart text field names validated by {@link ImportPackageSelectionRequestDto}. */
|
||||
export const IMPORT_PACKAGE_SELECTION_REQUEST_FORM_FIELDS = [
|
||||
'selectedProjectId',
|
||||
'selectedWorkflowIds',
|
||||
'deletedWorkflowIds',
|
||||
'workflowConflictPolicy',
|
||||
'workflowIdPolicy',
|
||||
] as const;
|
||||
|
||||
const SELECTED_WORKFLOW_IDS_ERROR_MESSAGE =
|
||||
'selectedWorkflowIds must be a JSON array of non-empty strings, e.g. ["id1","id2"]';
|
||||
const DELETED_WORKFLOW_IDS_ERROR_MESSAGE =
|
||||
'deletedWorkflowIds must be a JSON array of non-empty strings, e.g. ["id1","id2"]';
|
||||
|
||||
const idArraySchema = z.array(z.string().trim().min(1));
|
||||
|
||||
function parseIdArray(value: string, ctx: z.RefinementCtx, errorMessage: string): string[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: errorMessage });
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
const result = idArraySchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: errorMessage });
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multipart fields carry arrays as JSON text.
|
||||
* Reject blank or omitted fields, but accept `[]`.
|
||||
*/
|
||||
const requiredJsonStringIdArray = (errorMessage: string) =>
|
||||
z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value, ctx): string[] => {
|
||||
if (value === undefined || value.trim().length === 0) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: errorMessage });
|
||||
return z.NEVER;
|
||||
}
|
||||
return parseIdArray(value, ctx, errorMessage);
|
||||
});
|
||||
|
||||
/** Treat blank or omitted fields as `undefined`. */
|
||||
const optionalJsonStringIdArray = (errorMessage: string) =>
|
||||
z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value, ctx): string[] | undefined => {
|
||||
if (value === undefined || value.trim().length === 0) return undefined;
|
||||
return parseIdArray(value, ctx, errorMessage);
|
||||
});
|
||||
|
||||
export class ImportPackageSelectionRequestDto extends Z.class({
|
||||
selectedProjectId: z.string().trim().min(1),
|
||||
selectedWorkflowIds: requiredJsonStringIdArray(SELECTED_WORKFLOW_IDS_ERROR_MESSAGE),
|
||||
deletedWorkflowIds: optionalJsonStringIdArray(DELETED_WORKFLOW_IDS_ERROR_MESSAGE),
|
||||
workflowConflictPolicy: optionalEnum(['new-version', 'fail', 'skip'], 'new-version'),
|
||||
workflowIdPolicy: optionalEnum(['new', 'source'], 'source'),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { In, type SelectQueryBuilder } from '@n8n/typeorm';
|
|||
import type { Mocked } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { Project } from '../../entities';
|
||||
import type { Folder, Project, WorkflowEntity } from '../../entities';
|
||||
import { SharedWorkflow } from '../../entities';
|
||||
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
|
||||
import { SharedWorkflowRepository } from '../shared-workflow.repository';
|
||||
|
|
@ -26,6 +26,98 @@ describe('SharedWorkflowRepository', () => {
|
|||
vi.spyOn(sharedWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
|
||||
});
|
||||
|
||||
describe('findOwnedWorkflowRemovalCandidates', () => {
|
||||
const rootWorkflow = mock<WorkflowEntity>({
|
||||
id: 'root',
|
||||
name: 'Root workflow',
|
||||
parentFolder: null,
|
||||
});
|
||||
const folderWorkflow = mock<WorkflowEntity>({
|
||||
id: 'nested',
|
||||
name: 'Nested workflow',
|
||||
parentFolder: mock<Folder>({ id: 'folder' }),
|
||||
});
|
||||
const rootRow = mock<SharedWorkflow>({ workflow: rootWorkflow });
|
||||
const folderRow = mock<SharedWorkflow>({ workflow: folderWorkflow });
|
||||
|
||||
it('returns no candidates without querying for an empty list', async () => {
|
||||
expect(
|
||||
await sharedWorkflowRepository.findOwnedWorkflowRemovalCandidates('project', []),
|
||||
).toEqual([]);
|
||||
expect(entityManager.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('limits the query to distinct requested ids owned by the project and not archived', async () => {
|
||||
entityManager.find.mockResolvedValue([rootRow, folderRow]);
|
||||
|
||||
const result = await sharedWorkflowRepository.findOwnedWorkflowRemovalCandidates('project', [
|
||||
'root',
|
||||
'nested',
|
||||
'root',
|
||||
]);
|
||||
|
||||
expect(entityManager.find).toHaveBeenCalledExactlyOnceWith(SharedWorkflow, {
|
||||
where: {
|
||||
projectId: 'project',
|
||||
workflowId: In(['root', 'nested']),
|
||||
role: 'workflow:owner',
|
||||
workflow: { isArchived: false },
|
||||
},
|
||||
relations: { workflow: { parentFolder: true } },
|
||||
select: {
|
||||
workflowId: true,
|
||||
workflow: { id: true, name: true, parentFolder: { id: true } },
|
||||
},
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ id: 'root', name: 'Root workflow', parentFolderId: null },
|
||||
{ id: 'nested', name: 'Nested workflow', parentFolderId: 'folder' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('combines candidates from bounded queries for a large list', async () => {
|
||||
const middleIds = Array.from({ length: 9_999 }, (_, index) => `workflow-${index}`);
|
||||
entityManager.find.mockResolvedValueOnce([rootRow]).mockResolvedValueOnce([folderRow]);
|
||||
|
||||
const result = await sharedWorkflowRepository.findOwnedWorkflowRemovalCandidates('project', [
|
||||
'root',
|
||||
...middleIds,
|
||||
'root',
|
||||
'nested',
|
||||
]);
|
||||
|
||||
expect(entityManager.find).toHaveBeenCalledTimes(2);
|
||||
expect(entityManager.find).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
SharedWorkflow,
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
projectId: 'project',
|
||||
workflowId: In(['root', ...middleIds]),
|
||||
role: 'workflow:owner',
|
||||
workflow: { isArchived: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(entityManager.find).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
SharedWorkflow,
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
projectId: 'project',
|
||||
workflowId: In(['nested']),
|
||||
role: 'workflow:owner',
|
||||
workflow: { isArchived: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{ id: 'root', name: 'Root workflow', parentFolderId: null },
|
||||
{ id: 'nested', name: 'Nested workflow', parentFolderId: 'folder' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSharedPersonalWorkflowsCount', () => {
|
||||
it('should return count with correct joins and filters', async () => {
|
||||
queryBuilder.getCount.mockResolvedValue(5);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,38 @@ export class SharedWorkflowRepository extends BaseRepository<SharedWorkflow> {
|
|||
super(SharedWorkflow, dataSource.manager, transactionRunner);
|
||||
}
|
||||
|
||||
async findOwnedWorkflowRemovalCandidates(
|
||||
projectId: string,
|
||||
workflowIds: string[],
|
||||
): Promise<Array<{ id: string; name: string; parentFolderId: string | null }>> {
|
||||
const candidates: Array<{ id: string; name: string; parentFolderId: string | null }> = [];
|
||||
|
||||
for (const chunk of chunkIds([...new Set(workflowIds)])) {
|
||||
const rows = await this.find({
|
||||
where: {
|
||||
projectId,
|
||||
workflowId: In(chunk),
|
||||
role: 'workflow:owner',
|
||||
workflow: { isArchived: false },
|
||||
},
|
||||
relations: { workflow: { parentFolder: true } },
|
||||
select: {
|
||||
workflowId: true,
|
||||
workflow: { id: true, name: true, parentFolder: { id: true } },
|
||||
},
|
||||
});
|
||||
for (const { workflow } of rows) {
|
||||
candidates.push({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
parentFolderId: workflow.parentFolder?.id ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* SharedWorkflow maps workflows to projects, so user access is checked through
|
||||
* project relations with the supplied project roles.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import { randomCredentialPayload, type CredentialPayload } from '@n8n/backend-te
|
|||
import type { WorkflowEntity } from '@n8n/db';
|
||||
import { EXECUTE_WORKFLOW_NODE_TYPE, getSubworkflowId, type INode } from 'n8n-workflow';
|
||||
|
||||
import { DirectoryPackageWriter } from '../../io/directory/directory-package-writer';
|
||||
import { entityFilePath, workflowMetadataFilePath } from '../../io/manifest-entry';
|
||||
import type { PackageWriter } from '../../io/package-writer';
|
||||
import { TarPackageWriter } from '../../io/tar/tar-package-writer';
|
||||
import { FORMAT_VERSION } from '../../spec/constants';
|
||||
import type { PackageManifest } from '../../spec/manifest.schema';
|
||||
|
|
@ -261,11 +264,12 @@ export async function buildImportPackageBuffer(
|
|||
writer.writeFile('manifest.json', JSON.stringify(manifest));
|
||||
workflows.forEach((wf, idx) => {
|
||||
const { content, metadata } = workflowFiles(wf);
|
||||
writer.writeDirectory(`workflows/wf-${idx}`);
|
||||
writer.writeFile(`workflows/wf-${idx}/workflow.json`, JSON.stringify(content));
|
||||
const target = `workflows/wf-${idx}`;
|
||||
writer.writeDirectory(target);
|
||||
writer.writeFile(entityFilePath('workflows', target), JSON.stringify(content));
|
||||
if (options.workflowMetadata !== 'omit') {
|
||||
writer.writeFile(
|
||||
`workflows/wf-${idx}/workflow-metadata.json`,
|
||||
workflowMetadataFilePath(target),
|
||||
JSON.stringify(options.workflowMetadata ?? metadata),
|
||||
);
|
||||
}
|
||||
|
|
@ -354,12 +358,7 @@ export interface PackageVariableEntry {
|
|||
variable: SerializedVariable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a package at explicit target paths, so tests can shape the exact package layout
|
||||
* (top-level folders, nested folders, project-namespaced entities). Manifest entries are
|
||||
* derived from each entity's id/name and the given target.
|
||||
*/
|
||||
export async function buildEntityPackageBuffer(options: {
|
||||
export interface EntityPackageOptions {
|
||||
workflows?: PackageWorkflowEntry[];
|
||||
folders?: PackageFolderEntry[];
|
||||
projects?: PackageProjectEntry[];
|
||||
|
|
@ -367,8 +366,12 @@ export async function buildEntityPackageBuffer(options: {
|
|||
variables?: PackageVariableEntry[];
|
||||
manifestExtras?: Partial<PackageManifest>;
|
||||
sourceId?: string;
|
||||
}): Promise<Buffer> {
|
||||
const writer = new TarPackageWriter();
|
||||
}
|
||||
|
||||
async function writeEntityPackage(
|
||||
writer: PackageWriter,
|
||||
options: EntityPackageOptions,
|
||||
): Promise<void> {
|
||||
const workflows = options.workflows ?? [];
|
||||
const folders = options.folders ?? [];
|
||||
const projects = options.projects ?? [];
|
||||
|
|
@ -429,29 +432,48 @@ export async function buildEntityPackageBuffer(options: {
|
|||
};
|
||||
|
||||
// Manifest first: the reader/parser resolves it before reading any referenced file.
|
||||
writer.writeFile('manifest.json', JSON.stringify(manifest));
|
||||
await writer.writeFile('manifest.json', JSON.stringify(manifest));
|
||||
for (const { target, workflow } of workflows) {
|
||||
const { content, metadata } = workflowFiles(workflow);
|
||||
writer.writeDirectory(target);
|
||||
writer.writeFile(`${target}/workflow.json`, JSON.stringify(content));
|
||||
writer.writeFile(`${target}/workflow-metadata.json`, JSON.stringify(metadata));
|
||||
await writer.writeDirectory(target);
|
||||
await writer.writeFile(entityFilePath('workflows', target), JSON.stringify(content));
|
||||
await writer.writeFile(workflowMetadataFilePath(target), JSON.stringify(metadata));
|
||||
}
|
||||
for (const { target, folder } of folders) {
|
||||
writer.writeDirectory(target);
|
||||
writer.writeFile(`${target}/folder.json`, JSON.stringify(folder));
|
||||
await writer.writeDirectory(target);
|
||||
await writer.writeFile(entityFilePath('folders', target), JSON.stringify(folder));
|
||||
}
|
||||
for (const { target, project } of projects) {
|
||||
writer.writeDirectory(target);
|
||||
writer.writeFile(`${target}/project.json`, JSON.stringify(project));
|
||||
await writer.writeDirectory(target);
|
||||
await writer.writeFile(entityFilePath('projects', target), JSON.stringify(project));
|
||||
}
|
||||
for (const { target, dataTable } of dataTables) {
|
||||
writer.writeDirectory(target);
|
||||
writer.writeFile(`${target}/data-table.json`, JSON.stringify(dataTable));
|
||||
await writer.writeDirectory(target);
|
||||
await writer.writeFile(entityFilePath('dataTables', target), JSON.stringify(dataTable));
|
||||
}
|
||||
for (const { target, variable } of variables) {
|
||||
writer.writeDirectory(target);
|
||||
writer.writeFile(`${target}/variable.json`, JSON.stringify(variable));
|
||||
await writer.writeDirectory(target);
|
||||
await writer.writeFile(entityFilePath('variables', target), JSON.stringify(variable));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a package at explicit target paths, so tests can shape the exact package layout
|
||||
* (top-level folders, nested folders, project-namespaced entities). Manifest entries are
|
||||
* derived from each entity's id/name and the given target.
|
||||
*/
|
||||
export async function buildEntityPackageBuffer(options: EntityPackageOptions): Promise<Buffer> {
|
||||
const writer = new TarPackageWriter();
|
||||
await writeEntityPackage(writer, options);
|
||||
return await streamToBuffer(writer.finalize());
|
||||
}
|
||||
|
||||
/** Use the same package layout for directory and tar imports. */
|
||||
export async function buildEntityPackageDirectory(
|
||||
targetDir: string,
|
||||
options: EntityPackageOptions,
|
||||
): Promise<void> {
|
||||
const writer = new DirectoryPackageWriter(targetDir);
|
||||
await writeEntityPackage(writer, options);
|
||||
await writer.finalize();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,669 @@
|
|||
import { LicenseState } from '@n8n/backend-common';
|
||||
import {
|
||||
createTeamProject,
|
||||
createWorkflow,
|
||||
linkUserToProject,
|
||||
mockInstance,
|
||||
testDb,
|
||||
testModules,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import { ProjectRepository, WorkflowHistoryRepository, WorkflowRepository } from '@n8n/db';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ConflictError } from '@/errors/response-errors/conflict.error';
|
||||
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
|
||||
import { createCustomRoleWithScopeSlugs } from '@test-integration/db/roles';
|
||||
import { createMember, createOwner } from '@test-integration/db/users';
|
||||
import { LicenseMocker } from '@test-integration/license';
|
||||
import { initNodeTypes } from '@test-integration/utils';
|
||||
|
||||
import { N8nPackagesService } from '../n8n-packages.service';
|
||||
import type { ImportSelection, ImportSelectionRequest } from '../n8n-packages.types';
|
||||
import {
|
||||
buildEntityPackageDirectory,
|
||||
serializedProject,
|
||||
serializedWorkflow,
|
||||
subWorkflowRefOf,
|
||||
WIRE_VERSION_ID,
|
||||
workflowRequirementsFromWorkflows,
|
||||
type EntityPackageOptions,
|
||||
} from './fixtures/package-fixtures';
|
||||
import { executeWorkflowNode } from './utils/test-builders';
|
||||
|
||||
const licenseMocker = new LicenseMocker();
|
||||
|
||||
// Mock the active workflow manager to avoid starting triggers during publication.
|
||||
mockInstance(ActiveWorkflowManager);
|
||||
|
||||
/** Trigger node, needed so a workflow can be published. */
|
||||
function scheduleTriggerNode() {
|
||||
return {
|
||||
id: 'schedule-trigger',
|
||||
name: 'Schedule Trigger',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function findProject(id: string) {
|
||||
return await Container.get(ProjectRepository).findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async function findWorkflow(id: string) {
|
||||
return await Container.get(WorkflowRepository).findOne({
|
||||
where: { id },
|
||||
relations: { parentFolder: true },
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await testModules.loadModules(['n8n-packages']);
|
||||
await testDb.init();
|
||||
await initNodeTypes();
|
||||
licenseMocker.mockLicenseState(Container.get(LicenseState));
|
||||
licenseMocker.setDefaults({
|
||||
features: ['feat:projectRole:admin', 'feat:folders'],
|
||||
quotas: { 'quota:maxTeamProjects': 100 },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('importPackageSelectionFromDirectory', () => {
|
||||
let owner: User;
|
||||
let createdDirs: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate([
|
||||
'Folder',
|
||||
'Project',
|
||||
'ProjectRelation',
|
||||
'SharedWorkflow',
|
||||
'WorkflowEntity',
|
||||
'WorkflowHistory',
|
||||
]);
|
||||
licenseMocker.reset();
|
||||
owner = await createOwner();
|
||||
createdDirs = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
createdDirs.map(async (dir) => await rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function packageDir(options: EntityPackageOptions): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'n8n-import-selection-'));
|
||||
createdDirs.push(dir);
|
||||
await buildEntityPackageDirectory(dir, options);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function importSelection(
|
||||
sourceDir: string,
|
||||
selection: ImportSelection,
|
||||
overrides?: Partial<ImportSelectionRequest>,
|
||||
) {
|
||||
return await Container.get(N8nPackagesService).importPackageSelectionFromDirectory(
|
||||
{ user: owner, ...overrides },
|
||||
{ sourceDir },
|
||||
selection,
|
||||
);
|
||||
}
|
||||
|
||||
const twoWorkflowPackage: EntityPackageOptions = {
|
||||
projects: [{ target: 'projects/p1', project: serializedProject({ id: 'P1', name: 'p1' }) }],
|
||||
workflows: [
|
||||
{
|
||||
target: 'projects/p1/workflows/wfa',
|
||||
workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }),
|
||||
},
|
||||
{
|
||||
target: 'projects/p1/workflows/wfb',
|
||||
workflow: serializedWorkflow({ id: 'WFB', name: 'wfb' }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('imports only the selected workflows, leaving the rest of the package out', async () => {
|
||||
const result = await importSelection(await packageDir(twoWorkflowPackage), {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA'],
|
||||
});
|
||||
|
||||
expect(result.workflows).toHaveLength(1);
|
||||
expect(result.workflows[0]).toMatchObject({ sourceWorkflowId: 'WFA', status: 'created' });
|
||||
// The default ID policy preserves source IDs in the destination.
|
||||
expect(await findWorkflow('WFA')).not.toBeNull();
|
||||
expect(await findWorkflow('WFB')).toBeNull();
|
||||
expect(await findProject('P1')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('is additive: a re-import touches only the selection, never pre-existing target workflows', async () => {
|
||||
await importSelection(await packageDir(twoWorkflowPackage), {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA'],
|
||||
});
|
||||
const project = await Container.get(ProjectRepository).findOneOrFail({ where: { id: 'P1' } });
|
||||
const keeper = await createWorkflow({ name: 'Keeper' }, project);
|
||||
|
||||
const renamedDir = await packageDir({
|
||||
projects: [{ target: 'projects/p1', project: serializedProject({ id: 'P1', name: 'p1' }) }],
|
||||
workflows: [
|
||||
{
|
||||
target: 'projects/p1/workflows/wfa',
|
||||
workflow: serializedWorkflow({ id: 'WFA', name: 'wfa renamed' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await importSelection(renamedDir, {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA'],
|
||||
});
|
||||
|
||||
expect(result.workflows[0]).toMatchObject({ sourceWorkflowId: 'WFA', status: 'updated' });
|
||||
expect((await findWorkflow('WFA'))?.name).toBe('wfa renamed');
|
||||
expect(result.removedWorkflows).toEqual([]);
|
||||
const survivor = await findWorkflow(keeper.id);
|
||||
expect(survivor?.isArchived).toBe(false);
|
||||
expect(survivor?.name).toBe('Keeper');
|
||||
});
|
||||
|
||||
it('confines the import to the selected project, never touching another package project', async () => {
|
||||
const bystander = await createTeamProject('Bystander', owner);
|
||||
const untouched = await createWorkflow({ name: 'Untouched' }, bystander);
|
||||
|
||||
const sourceDir = await packageDir({
|
||||
projects: [
|
||||
{ target: 'projects/p1', project: serializedProject({ id: 'P1', name: 'p1' }) },
|
||||
{ target: 'projects/p2', project: serializedProject({ id: 'P2', name: 'p2' }) },
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
target: 'projects/p1/workflows/wfa',
|
||||
workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }),
|
||||
},
|
||||
{
|
||||
target: 'projects/p2/workflows/wfb',
|
||||
workflow: serializedWorkflow({ id: 'WFB', name: 'wfb' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await importSelection(sourceDir, {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA'],
|
||||
});
|
||||
|
||||
expect(result.projects.map((p) => p.localId)).toEqual(['P1']);
|
||||
expect(await findProject('P1')).not.toBeNull();
|
||||
expect(await findProject('P2')).toBeNull();
|
||||
expect(await findWorkflow('WFB')).toBeNull();
|
||||
expect((await findWorkflow(untouched.id))?.isArchived).toBe(false);
|
||||
});
|
||||
|
||||
it('drops selected ids that belong to another project', async () => {
|
||||
const sourceDir = await packageDir({
|
||||
projects: [
|
||||
{ target: 'projects/p1', project: serializedProject({ id: 'P1', name: 'p1' }) },
|
||||
{ target: 'projects/p2', project: serializedProject({ id: 'P2', name: 'p2' }) },
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
target: 'projects/p1/workflows/wfa',
|
||||
workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }),
|
||||
},
|
||||
{
|
||||
target: 'projects/p2/workflows/wfb',
|
||||
workflow: serializedWorkflow({ id: 'WFB', name: 'wfb' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await importSelection(sourceDir, {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA', 'WFB'],
|
||||
});
|
||||
|
||||
expect(result.workflows.map((w) => w.sourceWorkflowId)).toEqual(['WFA']);
|
||||
expect(await findWorkflow('WFB')).toBeNull();
|
||||
expect(await findProject('P2')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a selected project the package does not contain, writing nothing', async () => {
|
||||
await expect(
|
||||
importSelection(await packageDir(twoWorkflowPackage), {
|
||||
selectedProjectId: 'MISSING',
|
||||
selectedWorkflowIds: ['WFA'],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestError);
|
||||
|
||||
expect(await findProject('P1')).toBeNull();
|
||||
expect(await Container.get(WorkflowRepository).count()).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a non-project (workflow) directory package, like a whole-scope directory import', async () => {
|
||||
const workflowPackageDir = await packageDir({
|
||||
workflows: [
|
||||
{ target: 'workflows/wfa', workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }) },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
importSelection(workflowPackageDir, {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA'],
|
||||
}),
|
||||
).rejects.toThrow('Directory packages must contain projects');
|
||||
});
|
||||
|
||||
it('is a no-op for an empty working copy, like a whole-scope directory import', async () => {
|
||||
const emptyDir = await packageDir({});
|
||||
|
||||
const result = await importSelection(emptyDir, {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA'],
|
||||
});
|
||||
|
||||
expect(result.projects).toHaveLength(0);
|
||||
expect(result.workflows).toHaveLength(0);
|
||||
expect(await Container.get(WorkflowRepository).count()).toBe(0);
|
||||
});
|
||||
|
||||
it('honours an overridden workflowIdPolicy', async () => {
|
||||
const result = await importSelection(
|
||||
await packageDir(twoWorkflowPackage),
|
||||
{ selectedProjectId: 'P1', selectedWorkflowIds: ['WFA'] },
|
||||
{ workflowIdPolicy: 'new' },
|
||||
);
|
||||
|
||||
const summary = result.workflows[0];
|
||||
expect(summary.sourceWorkflowId).toBe('WFA');
|
||||
expect(summary.localId).not.toBe('WFA');
|
||||
expect(await findWorkflow(summary.localId)).not.toBeNull();
|
||||
expect(await findWorkflow('WFA')).toBeNull();
|
||||
});
|
||||
|
||||
it('honours an overridden workflowConflictPolicy', async () => {
|
||||
await importSelection(await packageDir(twoWorkflowPackage), {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA'],
|
||||
});
|
||||
|
||||
const renamedDir = await packageDir({
|
||||
projects: [{ target: 'projects/p1', project: serializedProject({ id: 'P1', name: 'p1' }) }],
|
||||
workflows: [
|
||||
{
|
||||
target: 'projects/p1/workflows/wfa',
|
||||
workflow: serializedWorkflow({ id: 'WFA', name: 'wfa renamed' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await importSelection(
|
||||
renamedDir,
|
||||
{ selectedProjectId: 'P1', selectedWorkflowIds: ['WFA'] },
|
||||
{ workflowConflictPolicy: 'skip' },
|
||||
);
|
||||
|
||||
expect(result.workflows[0]).toMatchObject({ sourceWorkflowId: 'WFA', status: 'skipped' });
|
||||
expect((await findWorkflow('WFA'))?.name).toBe('wfa');
|
||||
});
|
||||
|
||||
it('fails a conflicting re-import under workflowConflictPolicy=fail, changing nothing', async () => {
|
||||
await importSelection(await packageDir(twoWorkflowPackage), {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA'],
|
||||
});
|
||||
|
||||
const renamedDir = await packageDir({
|
||||
projects: [{ target: 'projects/p1', project: serializedProject({ id: 'P1', name: 'p1' }) }],
|
||||
workflows: [
|
||||
{
|
||||
target: 'projects/p1/workflows/wfa',
|
||||
workflow: serializedWorkflow({ id: 'WFA', name: 'wfa renamed' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
importSelection(
|
||||
renamedDir,
|
||||
{ selectedProjectId: 'P1', selectedWorkflowIds: ['WFA'] },
|
||||
{ workflowConflictPolicy: 'fail' },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
constructor: ConflictError,
|
||||
meta: {
|
||||
issues: [{ type: 'workflow-conflict', sourceWorkflowId: 'WFA', existingWorkflowId: 'WFA' }],
|
||||
},
|
||||
});
|
||||
|
||||
// The existing workflow is untouched: the blocked import writes nothing.
|
||||
expect((await findWorkflow('WFA'))?.name).toBe('wfa');
|
||||
});
|
||||
|
||||
it('imports an out-of-subset sub-workflow reference broken-but-preserved, soft-failing its publish', async () => {
|
||||
const parent = serializedWorkflow({
|
||||
id: 'CHEDDAR',
|
||||
name: 'Parent',
|
||||
publishedVersionId: WIRE_VERSION_ID,
|
||||
nodes: [scheduleTriggerNode(), executeWorkflowNode('BRIE')],
|
||||
});
|
||||
const sub = serializedWorkflow({
|
||||
id: 'BRIE',
|
||||
name: 'Sub-workflow',
|
||||
publishedVersionId: WIRE_VERSION_ID,
|
||||
nodes: [scheduleTriggerNode()],
|
||||
});
|
||||
const sourceDir = await packageDir({
|
||||
projects: [{ target: 'projects/p1', project: serializedProject({ id: 'P1', name: 'p1' }) }],
|
||||
workflows: [
|
||||
{ target: 'projects/p1/workflows/cheddar', workflow: parent },
|
||||
{ target: 'projects/p1/workflows/brie', workflow: sub },
|
||||
],
|
||||
manifestExtras: {
|
||||
requirements: { workflows: workflowRequirementsFromWorkflows([parent, sub]) },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await importSelection(sourceDir, {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['CHEDDAR'],
|
||||
});
|
||||
|
||||
const parentSummary = result.workflows.find((w) => w.sourceWorkflowId === 'CHEDDAR');
|
||||
expect(parentSummary?.status).toBe('created');
|
||||
const importedParent = await findWorkflow(parentSummary!.localId);
|
||||
expect(subWorkflowRefOf(importedParent!)).toBe('BRIE');
|
||||
expect(await findWorkflow('BRIE')).toBeNull();
|
||||
expect(parentSummary?.publishing.state).toBe('failed');
|
||||
expect(parentSummary?.publishing.error).toMatch(/BRIE.*not published/);
|
||||
});
|
||||
|
||||
it('imports the selected subset from a real exported directory', async () => {
|
||||
const sourceDir = await mkdtemp(path.join(tmpdir(), 'n8n-export-selection-'));
|
||||
createdDirs.push(sourceDir);
|
||||
|
||||
const project = await createTeamProject('Alpha Project', owner);
|
||||
const wfOne = await createWorkflow({ name: 'WF One', nodes: [], connections: {} }, project);
|
||||
const wfTwo = await createWorkflow({ name: 'WF Two', nodes: [], connections: {} }, project);
|
||||
await Container.get(N8nPackagesService).exportPackageToDirectory(
|
||||
{ user: owner, projectIds: [project.id] },
|
||||
{ targetDir: sourceDir },
|
||||
);
|
||||
|
||||
// Clear the workflows so the re-import must recreate exactly the selected subset.
|
||||
await testDb.truncate(['WorkflowEntity', 'SharedWorkflow']);
|
||||
|
||||
const result = await importSelection(sourceDir, {
|
||||
selectedProjectId: project.id,
|
||||
selectedWorkflowIds: [wfOne.id],
|
||||
});
|
||||
|
||||
expect(result.workflows.map((w) => w.sourceWorkflowId)).toEqual([wfOne.id]);
|
||||
expect(await findWorkflow(wfOne.id)).not.toBeNull();
|
||||
expect(await findWorkflow(wfTwo.id)).toBeNull();
|
||||
});
|
||||
|
||||
describe('explicit deletes', () => {
|
||||
it('rejects a selected create before creating the project or workflows', async () => {
|
||||
await expect(
|
||||
importSelection(await packageDir(twoWorkflowPackage), {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA', 'WFB'],
|
||||
deletedWorkflowIds: ['WFA'],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
constructor: ConflictError,
|
||||
meta: {
|
||||
issues: [
|
||||
{
|
||||
type: 'workflow-removal-conflict',
|
||||
sourceWorkflowId: 'WFA',
|
||||
workflowId: 'WFA',
|
||||
projectId: 'P1',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await findProject('P1')).toBeNull();
|
||||
expect(await findWorkflow('WFA')).toBeNull();
|
||||
expect(await findWorkflow('WFB')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ isArchived: false, workflowConflictPolicy: 'new-version', workflowIdPolicy: 'source' },
|
||||
{ isArchived: true, workflowConflictPolicy: 'new-version', workflowIdPolicy: 'source' },
|
||||
{ isArchived: false, workflowConflictPolicy: 'skip', workflowIdPolicy: 'source' },
|
||||
{ isArchived: false, workflowConflictPolicy: 'new-version', workflowIdPolicy: 'new' },
|
||||
] as const)(
|
||||
'rejects an overlap with archived=$isArchived, conflict=$workflowConflictPolicy, ids=$workflowIdPolicy',
|
||||
async ({ isArchived, workflowConflictPolicy, workflowIdPolicy }) => {
|
||||
const sourceDir = await packageDir(twoWorkflowPackage);
|
||||
const seeded = await importSelection(
|
||||
sourceDir,
|
||||
{ selectedProjectId: 'P1', selectedWorkflowIds: ['WFA'] },
|
||||
{ workflowIdPolicy },
|
||||
);
|
||||
const workflowId = seeded.workflows[0].localId;
|
||||
if (workflowIdPolicy === 'new') expect(workflowId).not.toBe('WFA');
|
||||
await Container.get(WorkflowRepository).update(workflowId, { isArchived });
|
||||
const before = await findWorkflow(workflowId);
|
||||
const historyBefore = await Container.get(WorkflowHistoryRepository).countBy({
|
||||
workflowId,
|
||||
});
|
||||
const projectBefore = await findProject('P1');
|
||||
|
||||
await expect(
|
||||
importSelection(
|
||||
sourceDir,
|
||||
{
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA', 'WFB'],
|
||||
deletedWorkflowIds: [workflowId],
|
||||
},
|
||||
{ workflowConflictPolicy },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
constructor: ConflictError,
|
||||
meta: {
|
||||
issues: [
|
||||
{
|
||||
type: 'workflow-removal-conflict',
|
||||
sourceWorkflowId: 'WFA',
|
||||
workflowId,
|
||||
projectId: 'P1',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await findWorkflow(workflowId)).toEqual(before);
|
||||
expect(await Container.get(WorkflowHistoryRepository).countBy({ workflowId })).toBe(
|
||||
historyBefore,
|
||||
);
|
||||
expect(await findWorkflow('WFB')).toBeNull();
|
||||
expect(await findProject('P1')).toEqual(projectBefore);
|
||||
},
|
||||
);
|
||||
|
||||
it('allows deletion of a workflow that is only referenced by the selection', async () => {
|
||||
await seedBothWorkflows();
|
||||
const parent = serializedWorkflow({
|
||||
id: 'WFA',
|
||||
name: 'Parent',
|
||||
nodes: [executeWorkflowNode('WFB')],
|
||||
});
|
||||
const sub = serializedWorkflow({ id: 'WFB', name: 'Sub' });
|
||||
const sourceDir = await packageDir({
|
||||
projects: twoWorkflowPackage.projects,
|
||||
workflows: [{ target: 'projects/p1/workflows/wfa', workflow: parent }],
|
||||
manifestExtras: {
|
||||
requirements: { workflows: workflowRequirementsFromWorkflows([parent, sub]) },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await importSelection(sourceDir, {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA'],
|
||||
deletedWorkflowIds: ['WFB'],
|
||||
});
|
||||
|
||||
expect(result.workflows).toEqual([
|
||||
expect.objectContaining({ sourceWorkflowId: 'WFA', status: 'updated' }),
|
||||
]);
|
||||
expect(result.removedWorkflows).toEqual([
|
||||
expect.objectContaining({ workflowId: 'WFB', deletion: 'archived' }),
|
||||
]);
|
||||
expect((await findWorkflow('WFB'))?.isArchived).toBe(true);
|
||||
expect(subWorkflowRefOf((await findWorkflow('WFA'))!)).toBe('WFB');
|
||||
});
|
||||
|
||||
async function seedBothWorkflows() {
|
||||
await importSelection(await packageDir(twoWorkflowPackage), {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA', 'WFB'],
|
||||
});
|
||||
}
|
||||
|
||||
it('archives a workflow named for deletion, even under the additive merge profile', async () => {
|
||||
await seedBothWorkflows();
|
||||
expect((await findWorkflow('WFB'))?.isArchived).toBe(false);
|
||||
|
||||
const result = await importSelection(await packageDir(twoWorkflowPackage), {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA'],
|
||||
deletedWorkflowIds: ['WFB'],
|
||||
});
|
||||
|
||||
expect(result.removedWorkflows).toEqual([
|
||||
{
|
||||
workflowId: 'WFB',
|
||||
name: 'wfb',
|
||||
projectId: 'P1',
|
||||
parentFolderId: null,
|
||||
deletion: 'archived',
|
||||
},
|
||||
]);
|
||||
expect((await findWorkflow('WFB'))?.isArchived).toBe(true);
|
||||
expect((await findWorkflow('WFA'))?.isArchived).toBe(false);
|
||||
});
|
||||
|
||||
it('tolerates deleting an already-archived or absent workflow as a no-op', async () => {
|
||||
await seedBothWorkflows();
|
||||
|
||||
await importSelection(await packageDir(twoWorkflowPackage), {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA'],
|
||||
deletedWorkflowIds: ['WFB'],
|
||||
});
|
||||
expect((await findWorkflow('WFB'))?.isArchived).toBe(true);
|
||||
|
||||
const result = await importSelection(await packageDir(twoWorkflowPackage), {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA'],
|
||||
deletedWorkflowIds: ['WFB', 'GHOST'],
|
||||
});
|
||||
|
||||
expect(result.removedWorkflows).toEqual([]);
|
||||
expect((await findWorkflow('WFB'))?.isArchived).toBe(true);
|
||||
});
|
||||
|
||||
it('confines deletes to the scoped project, never removing a bystander project workflow', async () => {
|
||||
await seedBothWorkflows();
|
||||
const bystander = await createTeamProject('Bystander', owner);
|
||||
const outsider = await createWorkflow({ name: 'Outsider' }, bystander);
|
||||
|
||||
const result = await importSelection(await packageDir(twoWorkflowPackage), {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA'],
|
||||
deletedWorkflowIds: [outsider.id],
|
||||
});
|
||||
|
||||
expect(result.removedWorkflows).toEqual([]);
|
||||
expect((await findWorkflow(outsider.id))?.isArchived).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an explicit delete the caller may not perform, writing nothing', async () => {
|
||||
const projectRepository = Container.get(ProjectRepository);
|
||||
await projectRepository.save(
|
||||
projectRepository.create({ id: 'P1', name: 'p1', type: 'team' }),
|
||||
);
|
||||
const project = await projectRepository.findOneOrFail({ where: { id: 'P1' } });
|
||||
const protectedWorkflow = await createWorkflow({ name: 'Protected' }, project);
|
||||
|
||||
// Grant import permissions without workflow:delete to test the project permission check.
|
||||
const member = await createMember();
|
||||
const importOnlyRole = await createCustomRoleWithScopeSlugs(
|
||||
[
|
||||
'project:read',
|
||||
'project:list',
|
||||
'project:update',
|
||||
'workflow:create',
|
||||
'workflow:read',
|
||||
'workflow:update',
|
||||
'workflow:import',
|
||||
'workflow:list',
|
||||
'workflow:publish',
|
||||
'folder:create',
|
||||
'folder:read',
|
||||
'folder:update',
|
||||
'folder:list',
|
||||
'credential:read',
|
||||
'credential:list',
|
||||
],
|
||||
{ roleType: 'project' },
|
||||
);
|
||||
await linkUserToProject(member, project, importOnlyRole.slug);
|
||||
|
||||
const sourceDir = await packageDir({
|
||||
projects: [{ target: 'projects/p1', project: serializedProject({ id: 'P1', name: 'p1' }) }],
|
||||
workflows: [
|
||||
{
|
||||
target: 'projects/p1/workflows/wfa',
|
||||
workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await importSelection(
|
||||
sourceDir,
|
||||
{
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA'],
|
||||
deletedWorkflowIds: [protectedWorkflow.id],
|
||||
},
|
||||
{ user: member },
|
||||
);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(UnprocessableRequestError);
|
||||
expect((caught as UnprocessableRequestError).meta?.issues).toContainEqual({
|
||||
type: 'workflow-removal-forbidden',
|
||||
workflowId: protectedWorkflow.id,
|
||||
name: 'Protected',
|
||||
projectId: 'P1',
|
||||
});
|
||||
|
||||
expect(await findWorkflow('WFA')).toBeNull();
|
||||
expect((await findWorkflow(protectedWorkflow.id))?.isArchived).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
|||
import {
|
||||
createN8nPackageMulterOptions,
|
||||
getPackageUploadFile,
|
||||
IMPORT_PACKAGE_SELECTION_BODY_FIELD_SET,
|
||||
listUploadFiles,
|
||||
resolveImportPackageUpload,
|
||||
} from '../../utils/import-package-upload';
|
||||
|
|
@ -144,4 +145,49 @@ describe('resolveImportPackageUpload', () => {
|
|||
}),
|
||||
).toThrow('Unexpected form field "evil"');
|
||||
});
|
||||
|
||||
describe('with the selection body field set', () => {
|
||||
it('accepts the selection form fields', () => {
|
||||
expect(() =>
|
||||
resolveImportPackageUpload(
|
||||
{
|
||||
files: [makeFile('package', packageBuffer)],
|
||||
body: {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: '["WFA"]',
|
||||
deletedWorkflowIds: '["WFB"]',
|
||||
workflowConflictPolicy: 'new-version',
|
||||
workflowIdPolicy: 'source',
|
||||
package: '',
|
||||
},
|
||||
},
|
||||
IMPORT_PACKAGE_SELECTION_BODY_FIELD_SET,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it.each(['folderConflictPolicy', 'projectId', 'folderId'])(
|
||||
'rejects the %s field, which the selection endpoint does not accept',
|
||||
(field) => {
|
||||
expect(() =>
|
||||
resolveImportPackageUpload(
|
||||
{
|
||||
files: [makeFile('package', packageBuffer)],
|
||||
body: { selectedProjectId: 'P1', [field]: 'x' },
|
||||
},
|
||||
IMPORT_PACKAGE_SELECTION_BODY_FIELD_SET,
|
||||
),
|
||||
).toThrow(`Unexpected form field "${field}"`);
|
||||
},
|
||||
);
|
||||
|
||||
it('leaves the default field set unchanged', () => {
|
||||
expect(() =>
|
||||
resolveImportPackageUpload({
|
||||
files: [makeFile('package', packageBuffer)],
|
||||
body: { selectedProjectId: 'P1' },
|
||||
}),
|
||||
).toThrow('Unexpected form field "selectedProjectId"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export function toImportBlockedError(
|
|||
issue.type === 'workflow-lineage-conflict' ||
|
||||
issue.type === 'workflow-id-conflict' ||
|
||||
issue.type === 'workflow-folder-conflict' ||
|
||||
issue.type === 'workflow-removal-conflict' ||
|
||||
issue.type === 'project-conflict' ||
|
||||
issue.type === 'folder-conflict' ||
|
||||
issue.type === 'variable-conflict' ||
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ export interface ImportOrchestrationInput {
|
|||
/** Sub-workflow dependency graph from the manifest, used to order the import. */
|
||||
subWorkflowRequirements?: PackageWorkflowRequirement[];
|
||||
importSource?: PackageImportSource;
|
||||
/** Destination workflow IDs to remove, even under `merge`. */
|
||||
explicitDeleteWorkflowIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -232,6 +234,7 @@ export class ImportOrchestrator {
|
|||
subWorkflowRequirementIds: input.subWorkflowRequirements?.map(({ id }) => id),
|
||||
projectPendingCreation: input.projectPendingCreation,
|
||||
importSource: input.importSource,
|
||||
explicitDeleteIds: input.explicitDeleteWorkflowIds,
|
||||
});
|
||||
|
||||
// Which folders end up empty depends on which workflows survive, so this follows the plan above
|
||||
|
|
@ -425,6 +428,9 @@ export class ImportOrchestrator {
|
|||
...removalPlan.failures.map(
|
||||
(failure): BlockingIssue => ({ type: 'workflow-removal-forbidden', ...failure }),
|
||||
),
|
||||
...removalPlan.conflicts.map(
|
||||
(conflict): BlockingIssue => ({ type: 'workflow-removal-conflict', ...conflict }),
|
||||
),
|
||||
...folderRemovalPlan.failures.map(
|
||||
(failure): BlockingIssue => ({ type: 'folder-removal-forbidden', ...failure }),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type {
|
|||
ImportBindingMap,
|
||||
ImportedFolderSummary,
|
||||
ImportedWorkflowSummary,
|
||||
ImportSelection,
|
||||
ResolvedImportRequest,
|
||||
ImportTagSummary,
|
||||
PackageImportBindings,
|
||||
|
|
@ -71,7 +72,15 @@ export class ProjectPackageImporter {
|
|||
): Promise<ImportOutcome> {
|
||||
this.assertAdequatePermissions(request, manifest);
|
||||
|
||||
const projects = await this.packageParser.getProjects(reader);
|
||||
const { selection } = request;
|
||||
const selectedProjects = selection
|
||||
? (manifest.projects ?? []).filter((project) => project.id === selection.selectedProjectId)
|
||||
: (manifest.projects ?? []);
|
||||
|
||||
const allProjects = await this.packageParser.getProjects(reader);
|
||||
const projects = selection
|
||||
? allProjects.filter((project) => project.sourceProjectId === selection.selectedProjectId)
|
||||
: allProjects;
|
||||
const projectPlan = await this.projectImporter.plan(
|
||||
request.user,
|
||||
projects,
|
||||
|
|
@ -104,7 +113,7 @@ export class ProjectPackageImporter {
|
|||
// Plan and validate every project's contents before writing anything, so a blocking issue in
|
||||
// any project leaves nothing behind — not folders, workflows, nor the project shells.
|
||||
const planned: Array<{ project: ManifestEntry; plan: ImportPlan }> = [];
|
||||
for (const project of manifest.projects ?? []) {
|
||||
for (const project of selectedProjects) {
|
||||
const input = await this.buildImportContextForProject(
|
||||
request,
|
||||
reader,
|
||||
|
|
@ -246,7 +255,14 @@ export class ProjectPackageImporter {
|
|||
): Promise<ImportOrchestrationInput> {
|
||||
const basePrefix = `${project.target}/`;
|
||||
const folders = await this.packageParser.getFolders(reader, basePrefix);
|
||||
const workflows = await this.packageParser.getWorkflows(reader, basePrefix);
|
||||
const allWorkflows = await this.packageParser.getWorkflows(reader, basePrefix);
|
||||
|
||||
// Do not expand the selection to include referenced sub-workflows.
|
||||
const workflows = request.selection
|
||||
? allWorkflows.filter((workflow) =>
|
||||
request.selection!.selectedWorkflowIds.includes(workflow.sourceWorkflowId),
|
||||
)
|
||||
: allWorkflows;
|
||||
|
||||
// Requirements and bindings are both scoped to this project's workflows so another project's
|
||||
// binding is not seen as an orphan here (which would block the whole multi-project import).
|
||||
|
|
@ -306,6 +322,7 @@ export class ProjectPackageImporter {
|
|||
// Scoped like the requirements above: reconciliation must retain a referenced-but-not-carried
|
||||
// sub-workflow, or it would archive a dependency and leave its packaged parent unpublishable.
|
||||
subWorkflowRequirements: identifyRequirements(manifest.requirements?.workflows, workflows),
|
||||
explicitDeleteWorkflowIds: deletesForProject(request.selection, project.id),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -336,5 +353,18 @@ export class ProjectPackageImporter {
|
|||
// Folders it empties go too, so it needs both removal scopes up front.
|
||||
assertPackageImportApiKeyScopes(request.apiKeyScopes, ['workflow:delete', 'folder:delete']);
|
||||
}
|
||||
|
||||
// Selection imports preserve folders, so explicit deletions need only workflow:delete.
|
||||
if (request.selection?.deletedWorkflowIds?.length) {
|
||||
assertPackageImportApiKeyScopes(request.apiKeyScopes, ['workflow:delete']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deletesForProject(
|
||||
selection: ImportSelection | undefined,
|
||||
projectId: string,
|
||||
): string[] | undefined {
|
||||
if (!selection || selection.selectedProjectId !== projectId) return undefined;
|
||||
return selection.deletedWorkflowIds;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ function makeRemover(placements: Placement[], archivableIds = placements.map(({
|
|||
workflowFinderService.findOwnedWorkflowPlacementsInProject.mockResolvedValue(
|
||||
placements.map((placement) => ({ isArchived: false, ...placement })),
|
||||
);
|
||||
workflowFinderService.findOwnedWorkflowRemovalCandidates.mockImplementation(
|
||||
async (_projectId, workflowIds) =>
|
||||
placements
|
||||
.filter(({ id, isArchived }) => workflowIds.includes(id) && !isArchived)
|
||||
.map(({ id, name, parentFolderId }) => ({ id, name, parentFolderId })),
|
||||
);
|
||||
workflowFinderService.findWorkflowIdsWithScopeForUser.mockResolvedValue(new Set(archivableIds));
|
||||
const workflowService = mock<WorkflowService>();
|
||||
return {
|
||||
|
|
@ -208,6 +214,7 @@ describe('WorkflowRemover.plan', () => {
|
|||
expect(plan).toEqual({
|
||||
removals: [],
|
||||
failures: [],
|
||||
conflicts: [],
|
||||
deletionPolicy: 'archive',
|
||||
occupiedFolderIds: [],
|
||||
});
|
||||
|
|
@ -215,10 +222,171 @@ describe('WorkflowRemover.plan', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('WorkflowRemover.planExplicitDeletes', () => {
|
||||
it.each(['create', 'update', 'skip'] as const)(
|
||||
'reports a conflict for a selected %s target',
|
||||
async (action) => {
|
||||
const item =
|
||||
action === 'create'
|
||||
? mock<Extract<WorkflowPlanItem, { action: 'create' }>>({
|
||||
action,
|
||||
decidedId: 'target',
|
||||
sourceWorkflowId: 'source',
|
||||
})
|
||||
: mock<Extract<WorkflowPlanItem, { action: 'update' | 'skip' }>>({
|
||||
action,
|
||||
existing: mock<WorkflowEntity>({ id: 'target' }),
|
||||
sourceWorkflowId: 'source',
|
||||
});
|
||||
const { remover, workflowFinderService } = makeRemover([]);
|
||||
|
||||
const plan = await remover.plan(context, {
|
||||
workflowItems: [item],
|
||||
packageFolderIds: [],
|
||||
folderConflictPolicy: 'merge',
|
||||
deletionPolicy: 'archive',
|
||||
explicitDeleteIds: ['target', 'target'],
|
||||
});
|
||||
|
||||
expect(plan.conflicts).toEqual([
|
||||
{ sourceWorkflowId: 'source', workflowId: 'target', projectId: 'proj-1' },
|
||||
]);
|
||||
expect(plan.removals).toEqual([]);
|
||||
expect(workflowFinderService.findOwnedWorkflowRemovalCandidates).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('allows an explicit delete of a referenced workflow that is not selected', async () => {
|
||||
const { remover } = makeRemover([{ id: 'sub', name: 'Sub', parentFolderId: null }]);
|
||||
|
||||
const plan = await remover.plan(context, {
|
||||
workflowItems: [created('parent')],
|
||||
subWorkflowRequirementIds: ['sub'],
|
||||
packageFolderIds: [],
|
||||
folderConflictPolicy: 'merge',
|
||||
deletionPolicy: 'archive',
|
||||
explicitDeleteIds: ['sub'],
|
||||
});
|
||||
|
||||
expect(plan.removals).toEqual([{ id: 'sub', name: 'Sub', parentFolderId: null }]);
|
||||
expect(plan.conflicts).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes an explicitly named workflow even under merge', async () => {
|
||||
const { remover, workflowFinderService } = makeRemover([
|
||||
{ id: 'target', name: 'Target', parentFolderId: 'F1' },
|
||||
{ id: 'bystander', name: 'Bystander', parentFolderId: null },
|
||||
]);
|
||||
|
||||
const plan = await remover.plan(context, {
|
||||
workflowItems: [],
|
||||
packageFolderIds: [],
|
||||
folderConflictPolicy: 'merge',
|
||||
deletionPolicy: 'archive',
|
||||
explicitDeleteIds: ['target', 'target'],
|
||||
});
|
||||
|
||||
expect(plan.removals).toEqual([{ id: 'target', name: 'Target', parentFolderId: 'F1' }]);
|
||||
expect(plan.failures).toEqual([]);
|
||||
expect(plan.occupiedFolderIds).toEqual([]);
|
||||
expect(
|
||||
workflowFinderService.findOwnedWorkflowRemovalCandidates,
|
||||
).toHaveBeenCalledExactlyOnceWith('proj-1', ['target']);
|
||||
expect(workflowFinderService.findOwnedWorkflowPlacementsInProject).not.toHaveBeenCalled();
|
||||
expect(workflowFinderService.findWorkflowIdsWithScopeForUser).toHaveBeenCalledExactlyOnceWith(
|
||||
['target'],
|
||||
user,
|
||||
['workflow:delete'],
|
||||
);
|
||||
});
|
||||
|
||||
it('under overwrite, deletes only the explicit target and never reconciles siblings', async () => {
|
||||
const { remover, workflowFinderService } = makeRemover([
|
||||
{ id: 'target', name: 'Target', parentFolderId: null },
|
||||
{ id: 'stale', name: 'Stale', parentFolderId: null },
|
||||
]);
|
||||
|
||||
const plan = await remover.plan(context, {
|
||||
workflowItems: [],
|
||||
packageFolderIds: [],
|
||||
folderConflictPolicy: 'overwrite',
|
||||
deletionPolicy: 'archive',
|
||||
explicitDeleteIds: ['target'],
|
||||
});
|
||||
|
||||
// Only the named target goes; the stale sibling the package omits is left untouched.
|
||||
expect(plan.removals).toEqual([{ id: 'target', name: 'Target', parentFolderId: null }]);
|
||||
expect(plan.failures).toEqual([]);
|
||||
// Reconcile-by-absence never runs even under overwrite: placements are not read, and the
|
||||
// permission query sees only the explicit id — not the stale sibling.
|
||||
expect(workflowFinderService.findOwnedWorkflowPlacementsInProject).not.toHaveBeenCalled();
|
||||
expect(
|
||||
workflowFinderService.findOwnedWorkflowRemovalCandidates,
|
||||
).toHaveBeenCalledExactlyOnceWith('proj-1', ['target']);
|
||||
expect(workflowFinderService.findWorkflowIdsWithScopeForUser).toHaveBeenCalledExactlyOnceWith(
|
||||
['target'],
|
||||
user,
|
||||
['workflow:delete'],
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a failure for a named workflow the caller may not delete', async () => {
|
||||
const { remover } = makeRemover([{ id: 'target', name: 'Target', parentFolderId: null }], []);
|
||||
|
||||
const plan = await remover.plan(context, {
|
||||
workflowItems: [],
|
||||
packageFolderIds: [],
|
||||
folderConflictPolicy: 'merge',
|
||||
deletionPolicy: 'archive',
|
||||
explicitDeleteIds: ['target'],
|
||||
});
|
||||
|
||||
expect(plan.removals).toEqual([]);
|
||||
expect(plan.failures).toEqual([{ workflowId: 'target', name: 'Target', projectId: 'proj-1' }]);
|
||||
});
|
||||
|
||||
it('treats an absent id as a no-op, skipping the permission query', async () => {
|
||||
const { remover, workflowFinderService } = makeRemover([
|
||||
{ id: 'present', name: 'Present', parentFolderId: null },
|
||||
]);
|
||||
|
||||
const plan = await remover.plan(context, {
|
||||
workflowItems: [],
|
||||
packageFolderIds: [],
|
||||
folderConflictPolicy: 'merge',
|
||||
deletionPolicy: 'archive',
|
||||
explicitDeleteIds: ['absent'],
|
||||
});
|
||||
|
||||
expect(plan.removals).toEqual([]);
|
||||
expect(plan.failures).toEqual([]);
|
||||
expect(workflowFinderService.findWorkflowIdsWithScopeForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats an already-archived id as a no-op', async () => {
|
||||
const { remover, workflowFinderService } = makeRemover([
|
||||
{ id: 'gone', name: 'Gone', parentFolderId: null, isArchived: true },
|
||||
]);
|
||||
|
||||
const plan = await remover.plan(context, {
|
||||
workflowItems: [],
|
||||
packageFolderIds: [],
|
||||
folderConflictPolicy: 'merge',
|
||||
deletionPolicy: 'archive',
|
||||
explicitDeleteIds: ['gone'],
|
||||
});
|
||||
|
||||
expect(plan.removals).toEqual([]);
|
||||
expect(plan.failures).toEqual([]);
|
||||
expect(workflowFinderService.findWorkflowIdsWithScopeForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkflowRemover.apply', () => {
|
||||
const planWith = (deletionPolicy: OverwriteDeletionPolicy): WorkflowRemovalPlan => ({
|
||||
removals: [{ id: 'stale', name: 'Stale', parentFolderId: 'F1' }],
|
||||
failures: [],
|
||||
conflicts: [],
|
||||
deletionPolicy,
|
||||
occupiedFolderIds: [],
|
||||
});
|
||||
|
|
@ -273,6 +441,7 @@ describe('WorkflowRemover.apply', () => {
|
|||
const summaries = await remover.apply(context, {
|
||||
removals: [],
|
||||
failures: [],
|
||||
conflicts: [],
|
||||
deletionPolicy: 'hard-delete',
|
||||
occupiedFolderIds: [],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ import type {
|
|||
OverwriteDeletionPolicy,
|
||||
PackageImportSource,
|
||||
WorkflowRemovalFailure,
|
||||
WorkflowRemovalConflict,
|
||||
} from '../../n8n-packages.types';
|
||||
|
||||
/** What the package accounts for in one project scope, against which the target is reconciled. */
|
||||
export interface WorkflowRemovalRequest {
|
||||
/** Decides whether reconciliation applies at all; only `overwrite` removes anything. */
|
||||
/** Only `overwrite` removes workflows omitted from the package. */
|
||||
folderConflictPolicy: FolderConflictPolicy;
|
||||
deletionPolicy: OverwriteDeletionPolicy;
|
||||
/** The decided plan for the package's own workflows; their target ids are retained. */
|
||||
|
|
@ -21,6 +22,11 @@ export interface WorkflowRemovalRequest {
|
|||
projectPendingCreation?: boolean;
|
||||
/** Git pulls reconcile all folders; package imports preserve folders they do not represent. */
|
||||
importSource?: PackageImportSource;
|
||||
/**
|
||||
* A non-empty list limits removal to these destination workflow IDs, regardless of the folder
|
||||
* conflict policy. An empty list uses the normal reconciliation rules.
|
||||
*/
|
||||
explicitDeleteIds?: string[];
|
||||
}
|
||||
|
||||
/** A workflow on the target that the package does not account for. */
|
||||
|
|
@ -33,6 +39,7 @@ export interface RemovableWorkflow {
|
|||
export interface WorkflowRemovalPlan {
|
||||
removals: RemovableWorkflow[];
|
||||
failures: WorkflowRemovalFailure[];
|
||||
conflicts: WorkflowRemovalConflict[];
|
||||
deletionPolicy: OverwriteDeletionPolicy;
|
||||
/**
|
||||
* Folders still holding a workflow once the removals above are done, so folder reconciliation
|
||||
|
|
|
|||
|
|
@ -33,10 +33,16 @@ export class WorkflowRemover {
|
|||
const nothingToRemove = {
|
||||
removals: [],
|
||||
failures: [],
|
||||
conflicts: [],
|
||||
deletionPolicy: request.deletionPolicy,
|
||||
occupiedFolderIds: [],
|
||||
};
|
||||
|
||||
// Explicit deletions must apply even when the folder policy disables reconciliation.
|
||||
if (request.explicitDeleteIds?.length) {
|
||||
return await this.planExplicitDeletes(context, request);
|
||||
}
|
||||
|
||||
// Owned here rather than by the caller: the policy that turns reconciliation on is this
|
||||
// service's concern, and a project being created holds nothing to reconcile against.
|
||||
if (
|
||||
|
|
@ -70,6 +76,7 @@ export class WorkflowRemover {
|
|||
|
||||
return {
|
||||
removals,
|
||||
conflicts: [],
|
||||
failures: candidates
|
||||
.filter(({ id }) => !removable.has(id))
|
||||
.map(({ id, name }) => ({ workflowId: id, name, projectId: context.projectId })),
|
||||
|
|
@ -78,6 +85,63 @@ export class WorkflowRemover {
|
|||
};
|
||||
}
|
||||
|
||||
private async planExplicitDeletes(
|
||||
context: ImportContext,
|
||||
request: WorkflowRemovalRequest,
|
||||
): Promise<WorkflowRemovalPlan> {
|
||||
const requested = new Set(request.explicitDeleteIds);
|
||||
// Check planned target ids before reading placements so absent and archived targets count too.
|
||||
const conflicts = request.workflowItems
|
||||
.filter((item) => requested.has(targetIdOf(item)))
|
||||
.map((item) => ({
|
||||
sourceWorkflowId: item.sourceWorkflowId,
|
||||
workflowId: targetIdOf(item),
|
||||
projectId: context.projectId,
|
||||
}));
|
||||
if (conflicts.length > 0) {
|
||||
return {
|
||||
removals: [],
|
||||
failures: [],
|
||||
conflicts,
|
||||
deletionPolicy: request.deletionPolicy,
|
||||
occupiedFolderIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const targets = await this.workflowFinderService.findOwnedWorkflowRemovalCandidates(
|
||||
context.projectId,
|
||||
[...requested],
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
return {
|
||||
removals: [],
|
||||
failures: [],
|
||||
conflicts: [],
|
||||
deletionPolicy: request.deletionPolicy,
|
||||
occupiedFolderIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const authorized = await this.workflowFinderService.findWorkflowIdsWithScopeForUser(
|
||||
targets.map(({ id }) => id),
|
||||
context.user,
|
||||
['workflow:delete'],
|
||||
);
|
||||
|
||||
return {
|
||||
removals: targets
|
||||
.filter(({ id }) => authorized.has(id))
|
||||
.map(({ id, name, parentFolderId }) => ({ id, name, parentFolderId })),
|
||||
conflicts: [],
|
||||
failures: targets
|
||||
.filter(({ id }) => !authorized.has(id))
|
||||
.map(({ id, name }) => ({ workflowId: id, name, projectId: context.projectId })),
|
||||
deletionPolicy: request.deletionPolicy,
|
||||
// Selection imports use `merge`, so folder reconciliation does not read these placements.
|
||||
occupiedFolderIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
async apply(
|
||||
context: ImportContext,
|
||||
plan: WorkflowRemovalPlan,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ import { PackageImportConfig } from './n8n-packages.config';
|
|||
import {
|
||||
CredentialExportPolicy,
|
||||
MissingWorkflowDependencyPolicy,
|
||||
WorkflowConflictPolicy,
|
||||
WorkflowIdPolicy,
|
||||
WorkflowVersionPolicy,
|
||||
type ExportPackageEventCounts,
|
||||
type ExportPackageRequest,
|
||||
|
|
@ -51,10 +53,14 @@ import {
|
|||
type ExportPackageResult,
|
||||
type ExportPackageSummary,
|
||||
type ImportPackageRequest,
|
||||
type ImportPackageSelectionRequest,
|
||||
type ImportRequest,
|
||||
type ImportResult,
|
||||
type ImportSelection,
|
||||
type ImportSelectionRequest,
|
||||
type PackageImportSource,
|
||||
type ResolvedImportPackageRequest,
|
||||
type ResolvedImportRequest,
|
||||
createBindings,
|
||||
} from './n8n-packages.types';
|
||||
import { FORMAT_VERSION } from './spec/constants';
|
||||
|
|
@ -75,6 +81,38 @@ interface WrittenExport {
|
|||
includeArchivedWorkflows: boolean;
|
||||
}
|
||||
|
||||
type DirectoryProjectPackage =
|
||||
| { status: 'empty'; result: ImportResult }
|
||||
| { status: 'project'; reader: PackageReader; manifest: PackageManifest };
|
||||
|
||||
/** Merge preserves workflows omitted from the selection. Skip preserves existing shared tags. */
|
||||
const CHERRY_PICK_IMPORT_POLICY = {
|
||||
projectConflictPolicy: 'merge',
|
||||
folderConflictPolicy: 'merge',
|
||||
overwriteDeletionPolicy: 'archive',
|
||||
workflowPublishingPolicy: 'match-source',
|
||||
missingNodeTypeMode: 'fail',
|
||||
credentialMatchingMode: 'id-only',
|
||||
credentialMissingMode: 'must-preexist',
|
||||
dataTableMatchingMode: 'by-id',
|
||||
dataTableMissingMode: 'create',
|
||||
dataTableSchemaConflictPolicy: 'fail',
|
||||
variableMissingMode: 'must-preexist',
|
||||
variableConflictPolicy: 'keep-existing',
|
||||
tagMissingMode: 'create',
|
||||
tagConflictPolicy: 'skip',
|
||||
} as const satisfies Omit<
|
||||
ResolvedImportRequest,
|
||||
| 'user'
|
||||
| 'projectId'
|
||||
| 'folderId'
|
||||
| 'apiKeyScopes'
|
||||
| 'bindings'
|
||||
| 'selection'
|
||||
| 'workflowConflictPolicy'
|
||||
| 'workflowIdPolicy'
|
||||
>;
|
||||
|
||||
@Service()
|
||||
export class N8nPackagesService {
|
||||
constructor(
|
||||
|
|
@ -401,17 +439,109 @@ export class N8nPackagesService {
|
|||
request: ImportRequest,
|
||||
source: { sourceDir: string },
|
||||
): Promise<ImportResult> {
|
||||
const opened = await this.readDirectoryProjectPackage(source);
|
||||
if (opened.status === 'empty') return opened.result;
|
||||
const { result } = await this.dispatchImport(
|
||||
request,
|
||||
opened.reader,
|
||||
opened.manifest,
|
||||
'git-pull',
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async importPackageSelectionFromDirectory(
|
||||
request: ImportSelectionRequest,
|
||||
source: { sourceDir: string },
|
||||
selection: ImportSelection,
|
||||
): Promise<ImportResult> {
|
||||
const opened = await this.readDirectoryProjectPackage(source);
|
||||
if (opened.status === 'empty') return opened.result;
|
||||
const { result } = await this.dispatchSelectionImport(
|
||||
request,
|
||||
opened.reader,
|
||||
opened.manifest,
|
||||
selection,
|
||||
'git-pull',
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Emit import telemetry for public API requests. Directory imports use the Git pull path. */
|
||||
async importPackageSelection(
|
||||
request: ImportPackageSelectionRequest,
|
||||
selection: ImportSelection,
|
||||
): Promise<ImportResult> {
|
||||
const reader = new TarPackageReader(request.packageBuffer, this.packageImportConfig);
|
||||
const manifest = await this.packageParser.getManifest(reader);
|
||||
if (!isProjectPackage(manifest)) {
|
||||
throw new BadRequestError('A selection import requires a project package.');
|
||||
}
|
||||
const { result, scopes, resolvedRequest } = await this.dispatchSelectionImport(
|
||||
request,
|
||||
reader,
|
||||
manifest,
|
||||
selection,
|
||||
'package-import',
|
||||
);
|
||||
|
||||
emitPackageImportedEvent(this.eventService, {
|
||||
request: { ...resolvedRequest, packageBuffer: request.packageBuffer },
|
||||
manifest,
|
||||
scopes,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** An empty working copy needs no import. Reject content without a project. */
|
||||
private async readDirectoryProjectPackage(source: {
|
||||
sourceDir: string;
|
||||
}): Promise<DirectoryProjectPackage> {
|
||||
const reader = new DirectoryPackageReader(source.sourceDir, this.packageImportConfig);
|
||||
await reader.listEntries();
|
||||
const manifest = await this.packageParser.getManifest(reader);
|
||||
if (!isProjectPackage(manifest)) {
|
||||
if (hasContentWithoutProjects(manifest)) {
|
||||
throw new BadRequestError('Directory packages must contain projects');
|
||||
}
|
||||
return emptyImportResult(manifest);
|
||||
if (isProjectPackage(manifest)) {
|
||||
return { status: 'project', reader, manifest };
|
||||
}
|
||||
const { result } = await this.dispatchImport(request, reader, manifest, 'git-pull');
|
||||
return result;
|
||||
if (hasContentWithoutProjects(manifest)) {
|
||||
throw new BadRequestError('Directory packages must contain projects');
|
||||
}
|
||||
return { status: 'empty', result: emptyImportResult(manifest) };
|
||||
}
|
||||
|
||||
/** The caller must validate that the manifest describes a project package. */
|
||||
private async dispatchSelectionImport(
|
||||
request: ImportSelectionRequest,
|
||||
reader: PackageReader,
|
||||
manifest: PackageManifest,
|
||||
selection: ImportSelection,
|
||||
importSource: PackageImportSource,
|
||||
): Promise<ImportOutcome & { resolvedRequest: ResolvedImportRequest }> {
|
||||
const packageProjectIds = new Set((manifest.projects ?? []).map((project) => project.id));
|
||||
if (!packageProjectIds.has(selection.selectedProjectId)) {
|
||||
throw new BadRequestError(
|
||||
`The selected project "${selection.selectedProjectId}" is not present in the package.`,
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedRequest: ResolvedImportRequest = {
|
||||
user: request.user,
|
||||
...(request.apiKeyScopes !== undefined ? { apiKeyScopes: request.apiKeyScopes } : {}),
|
||||
...(request.bindings !== undefined ? { bindings: request.bindings } : {}),
|
||||
...CHERRY_PICK_IMPORT_POLICY,
|
||||
workflowConflictPolicy: request.workflowConflictPolicy ?? WorkflowConflictPolicy.NewVersion,
|
||||
workflowIdPolicy: request.workflowIdPolicy ?? WorkflowIdPolicy.Source,
|
||||
selection,
|
||||
};
|
||||
|
||||
const outcome = await this.projectPackageImporter.import(
|
||||
resolvedRequest,
|
||||
reader,
|
||||
manifest,
|
||||
importSource,
|
||||
);
|
||||
return { ...outcome, resolvedRequest };
|
||||
}
|
||||
|
||||
private async dispatchImport(
|
||||
|
|
|
|||
|
|
@ -255,6 +255,8 @@ export type ImportRequest = {
|
|||
folderId?: string;
|
||||
bindings?: Partial<PackageImportBindings>;
|
||||
apiKeyScopes?: string[];
|
||||
/** Omit to import the whole package. */
|
||||
selection?: ImportSelection;
|
||||
} & ImportCredentialProperties &
|
||||
ImportWorkflowProperties &
|
||||
ImportProjectProperties &
|
||||
|
|
@ -267,6 +269,37 @@ export type ImportPackageRequest = ImportRequest & {
|
|||
packageBuffer: Buffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Import only the selected workflows. Do not add referenced sub-workflows to the selection.
|
||||
*
|
||||
* Delete only within the destination project, even under `merge`. Ignore absent or archived IDs.
|
||||
* References to deleted workflows remain unchanged.
|
||||
*/
|
||||
export interface ImportSelection {
|
||||
/** Source project ID from the package. */
|
||||
selectedProjectId: string;
|
||||
/** Source workflow IDs from the selected project. */
|
||||
selectedWorkflowIds: string[];
|
||||
/** Destination workflow IDs to remove. */
|
||||
deletedWorkflowIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Match or create the destination project from the package. Callers cannot override its location.
|
||||
* The service fixes all policies except `workflowConflictPolicy` and `workflowIdPolicy`.
|
||||
*/
|
||||
export type ImportSelectionRequest = {
|
||||
user: User;
|
||||
apiKeyScopes?: string[];
|
||||
bindings?: Partial<PackageImportBindings>;
|
||||
workflowConflictPolicy?: WorkflowConflictPolicy;
|
||||
workflowIdPolicy?: WorkflowIdPolicy;
|
||||
};
|
||||
|
||||
export type ImportPackageSelectionRequest = ImportSelectionRequest & {
|
||||
packageBuffer: Buffer;
|
||||
};
|
||||
|
||||
export type ImportCredentialProperties = {
|
||||
credentialMatchingMode: CredentialMatchingMode;
|
||||
credentialMissingMode: CredentialMissingMode;
|
||||
|
|
@ -520,6 +553,7 @@ export type BlockingIssue =
|
|||
| ({ type: 'project-conflict' } & ProjectConflict)
|
||||
| ({ type: 'folder-conflict' } & FolderConflict)
|
||||
| ({ type: 'workflow-removal-forbidden' } & WorkflowRemovalFailure)
|
||||
| ({ type: 'workflow-removal-conflict' } & WorkflowRemovalConflict)
|
||||
| ({ type: 'folder-removal-forbidden' } & FolderRemovalFailure)
|
||||
| ({ type: 'data-table-unresolved' } & DataTableResolutionFailure)
|
||||
| ({ type: 'tag-unresolved' } & TagResolutionFailure)
|
||||
|
|
@ -551,6 +585,13 @@ export interface WorkflowRemovalFailure {
|
|||
projectId: string;
|
||||
}
|
||||
|
||||
/** A selected workflow also named for explicit removal. */
|
||||
export interface WorkflowRemovalConflict {
|
||||
sourceWorkflowId: string;
|
||||
workflowId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A folder `folderConflictPolicy=overwrite` would delete that the caller may not delete.
|
||||
* Blocking for the same reason as workflow removal: a partial reconciliation leaves the
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { IMPORT_PACKAGE_REQUEST_FORM_FIELDS } from '@n8n/api-types';
|
||||
import {
|
||||
IMPORT_PACKAGE_REQUEST_FORM_FIELDS,
|
||||
IMPORT_PACKAGE_SELECTION_REQUEST_FORM_FIELDS,
|
||||
} from '@n8n/api-types';
|
||||
import type { GlobalConfig } from '@n8n/config';
|
||||
import multer from 'multer';
|
||||
|
||||
|
|
@ -8,11 +11,17 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
|||
* Allowed keys on `req.body` after multipart parsing. Includes `package` because
|
||||
* express-openapi-validator inserts an empty-string placeholder for file parts.
|
||||
*/
|
||||
const IMPORT_PACKAGE_BODY_FIELD_SET = new Set<string>([
|
||||
export const IMPORT_PACKAGE_BODY_FIELD_SET = new Set<string>([
|
||||
...IMPORT_PACKAGE_REQUEST_FORM_FIELDS,
|
||||
'package',
|
||||
]);
|
||||
|
||||
/** Include `package` because express-openapi-validator adds a placeholder for the file part. */
|
||||
export const IMPORT_PACKAGE_SELECTION_BODY_FIELD_SET = new Set<string>([
|
||||
...IMPORT_PACKAGE_SELECTION_REQUEST_FORM_FIELDS,
|
||||
'package',
|
||||
]);
|
||||
|
||||
/** Max length for multipart text fields, including JSON credential bindings. */
|
||||
const IMPORT_PACKAGE_FIELD_SIZE_BYTES = 64 * 1024;
|
||||
|
||||
|
|
@ -50,10 +59,13 @@ export function getPackageUploadFile(req: {
|
|||
return files.find((file) => file.fieldname === 'package');
|
||||
}
|
||||
|
||||
export function resolveImportPackageUpload(req: {
|
||||
files?: Express.Multer.File[] | Record<string, Express.Multer.File[]>;
|
||||
body?: Record<string, unknown>;
|
||||
}): Express.Multer.File {
|
||||
export function resolveImportPackageUpload(
|
||||
req: {
|
||||
files?: Express.Multer.File[] | Record<string, Express.Multer.File[]>;
|
||||
body?: Record<string, unknown>;
|
||||
},
|
||||
allowedBodyFields: Set<string> = IMPORT_PACKAGE_BODY_FIELD_SET,
|
||||
): Express.Multer.File {
|
||||
const packageFile = getPackageUploadFile(req);
|
||||
if (!packageFile?.buffer?.length) {
|
||||
throw new BadRequestError('Multipart field "package" is required');
|
||||
|
|
@ -66,7 +78,7 @@ export function resolveImportPackageUpload(req: {
|
|||
}
|
||||
|
||||
for (const key of Object.keys(req.body ?? {})) {
|
||||
if (!IMPORT_PACKAGE_BODY_FIELD_SET.has(key)) {
|
||||
if (!allowedBodyFields.has(key)) {
|
||||
throw new BadRequestError(`Unexpected form field "${key}"`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,20 @@ export declare namespace PackageRequest {
|
|||
{ projectId?: string; folderId?: string },
|
||||
Record<string, never>
|
||||
>;
|
||||
|
||||
type ImportSelection = AuthenticatedRequest<
|
||||
{},
|
||||
{},
|
||||
{
|
||||
selectedProjectId?: string;
|
||||
// Multipart text fields carrying JSON-string arrays; parsed by the DTO.
|
||||
selectedWorkflowIds?: string;
|
||||
deletedWorkflowIds?: string;
|
||||
workflowConflictPolicy?: string;
|
||||
workflowIdPolicy?: string;
|
||||
},
|
||||
Record<string, never>
|
||||
>;
|
||||
}
|
||||
|
||||
export declare namespace UserRequest {
|
||||
|
|
|
|||
|
|
@ -32,11 +32,13 @@ let handler: Record<string, Array<(...args: unknown[]) => unknown>>;
|
|||
// exportPackage/importPackage = [middleware(...), businessLogic]; index 1 is the handler under test.
|
||||
let exportPackage: (...args: unknown[]) => unknown;
|
||||
let importPackage: (...args: unknown[]) => unknown;
|
||||
let importPackageSelection: (...args: unknown[]) => unknown;
|
||||
|
||||
beforeAll(async () => {
|
||||
handler = (await import('../n8n-packages.handler.js')) as unknown as typeof handler;
|
||||
exportPackage = handler.exportPackage[1];
|
||||
importPackage = handler.importPackage[1];
|
||||
importPackageSelection = handler.importPackageSelection[1];
|
||||
});
|
||||
|
||||
const EXPORT_COUNTS = {
|
||||
|
|
@ -104,6 +106,21 @@ describe('n8n-packages handler', () => {
|
|||
return caught;
|
||||
}
|
||||
|
||||
function makeImportSelectionRequest(
|
||||
body: Record<string, unknown>,
|
||||
apiKeyScopes?: string[],
|
||||
files: Express.Multer.File[] = [
|
||||
{ fieldname: 'package', buffer: Buffer.from('tar-bytes') } as Express.Multer.File,
|
||||
],
|
||||
) {
|
||||
return {
|
||||
user: { id: 'user-1' },
|
||||
body: { selectedProjectId: 'P1', selectedWorkflowIds: '["WFA"]', ...body },
|
||||
files,
|
||||
tokenGrant: apiKeyScopes ? { apiKeyScopes } : undefined,
|
||||
} as unknown as AuthenticatedRequest;
|
||||
}
|
||||
|
||||
async function runImport(req: AuthenticatedRequest, res: Response) {
|
||||
let caught: unknown;
|
||||
try {
|
||||
|
|
@ -114,6 +131,16 @@ describe('n8n-packages handler', () => {
|
|||
return caught;
|
||||
}
|
||||
|
||||
async function runImportSelection(req: AuthenticatedRequest, res: Response) {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await importPackageSelection(req, res);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
return caught;
|
||||
}
|
||||
|
||||
function emittedEvent(name: 'n8n-package-export-failed' | 'n8n-package-import-failed') {
|
||||
const call = mockEventService.emit.mock.calls.find(([eventName]) => eventName === name);
|
||||
return call?.[1] as RelayEventMap[typeof name] | undefined;
|
||||
|
|
@ -790,4 +817,114 @@ describe('n8n-packages handler', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importPackageSelection', () => {
|
||||
it('throws ForbiddenError and emits access-denied when the API key lacks workflow:import scope', async () => {
|
||||
const caught = await runImportSelection(
|
||||
makeImportSelectionRequest({}, ['workflow:export']),
|
||||
makeResponse(),
|
||||
);
|
||||
|
||||
expect(caught).toBeInstanceOf(ForbiddenError);
|
||||
expect(mockService.importPackageSelection).not.toHaveBeenCalled();
|
||||
expect(emittedEvent('n8n-package-import-failed')).toEqual({
|
||||
user: { id: 'user-1' },
|
||||
reason: 'access-denied',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws BadRequestError and emits validation when selectedProjectId is missing', async () => {
|
||||
const caught = await runImportSelection(
|
||||
makeImportSelectionRequest({ selectedProjectId: '' }, ['workflow:import']),
|
||||
makeResponse(),
|
||||
);
|
||||
|
||||
expect(caught).toBeInstanceOf(BadRequestError);
|
||||
expect(mockService.importPackageSelection).not.toHaveBeenCalled();
|
||||
expect(emittedEvent('n8n-package-import-failed')).toMatchObject({ reason: 'validation' });
|
||||
});
|
||||
|
||||
it('throws BadRequestError when the multipart package file is missing', async () => {
|
||||
const caught = await runImportSelection(
|
||||
makeImportSelectionRequest({}, ['workflow:import'], []),
|
||||
makeResponse(),
|
||||
);
|
||||
|
||||
expect(caught).toBeInstanceOf(BadRequestError);
|
||||
expect(mockService.importPackageSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['projectId', 'folderId'])(
|
||||
'rejects a stray %s field, which the selection endpoint does not accept',
|
||||
async (field) => {
|
||||
const caught = await runImportSelection(
|
||||
makeImportSelectionRequest({ [field]: 'proj-brie' }, ['workflow:import']),
|
||||
makeResponse(),
|
||||
);
|
||||
|
||||
expect(caught).toBeInstanceOf(BadRequestError);
|
||||
expect(mockService.importPackageSelection).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('parses the DTO and forwards the selection to the service', async () => {
|
||||
const result = { package: {}, workflows: [], bindings: {}, credentials: {} };
|
||||
mockService.importPackageSelection.mockResolvedValue(result as never);
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
|
||||
|
||||
const caught = await runImportSelection(
|
||||
makeImportSelectionRequest(
|
||||
{
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: '["WFA","WFB"]',
|
||||
deletedWorkflowIds: '["WFC"]',
|
||||
workflowConflictPolicy: 'skip',
|
||||
workflowIdPolicy: 'new',
|
||||
},
|
||||
['workflow:import'],
|
||||
),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(caught).toBeUndefined();
|
||||
expect(mockService.importPackageSelection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user: { id: 'user-1' },
|
||||
apiKeyScopes: ['workflow:import'],
|
||||
workflowConflictPolicy: 'skip',
|
||||
workflowIdPolicy: 'new',
|
||||
packageBuffer: expect.any(Buffer),
|
||||
}),
|
||||
{
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA', 'WFB'],
|
||||
deletedWorkflowIds: ['WFC'],
|
||||
},
|
||||
);
|
||||
expect(mockEventService.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omits deletedWorkflowIds from the selection when the caller does not send it', async () => {
|
||||
const result = { package: {}, workflows: [], bindings: {}, credentials: {} };
|
||||
mockService.importPackageSelection.mockResolvedValue(result as never);
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
|
||||
|
||||
await runImportSelection(makeImportSelectionRequest({}, ['workflow:import']), res);
|
||||
|
||||
expect(mockService.importPackageSelection).toHaveBeenCalledWith(expect.any(Object), {
|
||||
selectedProjectId: 'P1',
|
||||
selectedWorkflowIds: ['WFA'],
|
||||
});
|
||||
});
|
||||
|
||||
it('emits blocked when the service rejects the import as blocked', async () => {
|
||||
mockService.importPackageSelection.mockRejectedValue(new ConflictError('Import blocked'));
|
||||
|
||||
await runImportSelection(makeImportSelectionRequest({}, ['workflow:import']), makeResponse());
|
||||
|
||||
expect(emittedEvent('n8n-package-import-failed')).toMatchObject({
|
||||
reason: 'blocked',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { ExportPackageRequestDto, ImportPackageRequestDto } from '@n8n/api-types';
|
||||
import {
|
||||
ExportPackageRequestDto,
|
||||
ImportPackageRequestDto,
|
||||
ImportPackageSelectionRequestDto,
|
||||
} from '@n8n/api-types';
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { ApiKeyScope } from '@n8n/permissions';
|
||||
|
|
@ -15,7 +19,10 @@ import {
|
|||
import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service';
|
||||
import type { ExportPackageResult } from '@/modules/n8n-packages/n8n-packages.types';
|
||||
import { classifyPackageFailure } from '@/modules/n8n-packages/package-failure-classifier';
|
||||
import { resolveImportPackageUpload } from '@/modules/n8n-packages/utils/import-package-upload';
|
||||
import {
|
||||
IMPORT_PACKAGE_SELECTION_BODY_FIELD_SET,
|
||||
resolveImportPackageUpload,
|
||||
} from '@/modules/n8n-packages/utils/import-package-upload';
|
||||
|
||||
import type { PackageRequest } from '../../../types';
|
||||
import type { PublicAPIEndpoint } from '../../shared/handler.types';
|
||||
|
|
@ -50,9 +57,14 @@ type ImportPackageRequest = PackageRequest.Import & {
|
|||
files?: Express.Multer.File[];
|
||||
};
|
||||
|
||||
type ImportPackageSelectionRequest = PackageRequest.ImportSelection & {
|
||||
files?: Express.Multer.File[];
|
||||
};
|
||||
|
||||
type N8nPackagesHandlers = {
|
||||
exportPackage: PublicAPIEndpoint<ExportPackageRequest>;
|
||||
importPackage: PublicAPIEndpoint<ImportPackageRequest>;
|
||||
importPackageSelection: PublicAPIEndpoint<ImportPackageSelectionRequest>;
|
||||
};
|
||||
|
||||
function assertPackageExportApiKeyScopes(
|
||||
|
|
@ -240,6 +252,48 @@ const n8nPackagesHandlers: N8nPackagesHandlers = {
|
|||
}
|
||||
},
|
||||
],
|
||||
importPackageSelection: [
|
||||
publicApiCompositeScope('workflow:import'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const payload = ImportPackageSelectionRequestDto.safeParse(req.body ?? {});
|
||||
if (!payload.success) {
|
||||
throw new BadRequestError(payload.error.errors.map(({ message }) => message).join('; '));
|
||||
}
|
||||
|
||||
assertPackageImportApiKeyScopes(req);
|
||||
|
||||
const packageFile = resolveImportPackageUpload(
|
||||
req,
|
||||
IMPORT_PACKAGE_SELECTION_BODY_FIELD_SET,
|
||||
);
|
||||
|
||||
const result = await Container.get(N8nPackagesService).importPackageSelection(
|
||||
{
|
||||
user: req.user,
|
||||
apiKeyScopes: req.tokenGrant?.apiKeyScopes,
|
||||
workflowConflictPolicy: payload.data.workflowConflictPolicy,
|
||||
workflowIdPolicy: payload.data.workflowIdPolicy,
|
||||
packageBuffer: packageFile.buffer,
|
||||
},
|
||||
{
|
||||
selectedProjectId: payload.data.selectedProjectId,
|
||||
selectedWorkflowIds: payload.data.selectedWorkflowIds,
|
||||
...(payload.data.deletedWorkflowIds !== undefined
|
||||
? { deletedWorkflowIds: payload.data.deletedWorkflowIds }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
Container.get(EventService).emit('n8n-package-import-failed', {
|
||||
user: req.user,
|
||||
reason: classifyPackageFailure(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export = n8nPackagesHandlers;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
post:
|
||||
x-eov-operation-id: importPackageSelection
|
||||
x-required-scope: workflow:import
|
||||
x-eov-operation-handler: v1/handlers/n8n-packages/n8n-packages.handler
|
||||
tags:
|
||||
- N8nPackage
|
||||
summary: 'Beta: Import a selected subset of workflows from an n8n package'
|
||||
description: |
|
||||
**Beta** — breaking changes may still occur without major version bump.
|
||||
|
||||
Imports a chosen subset of workflows from one source project of a gzip-compressed tar
|
||||
package (`.n8np`), instead of the whole package. Send the archive as the multipart field
|
||||
`package`. The import is **additive** (cherry-pick): it never removes a workflow the
|
||||
selection omits. It creates or updates only the workflows named in `selectedWorkflowIds`,
|
||||
and deletes only the destination workflows named in `deletedWorkflowIds`.
|
||||
|
||||
`selectedProjectId` names the single source project the selection is scoped to (its id as it
|
||||
appears in the package). The selected workflows are written into the instance project whose id
|
||||
matches that source project, which is created if it does not yet exist; there is no separate
|
||||
target-project field. `selectedWorkflowIds` are source ids within that project; ids that belong
|
||||
to another project are dropped. `deletedWorkflowIds` are destination ids in the same project; an
|
||||
already-archived or absent id is a tolerated no-op, and a delete needs the `workflow:delete`
|
||||
scope. A delete id must not match the destination id of a selected workflow, including
|
||||
when `workflowConflictPolicy=skip`. This overlap returns a 409 before any writes.
|
||||
|
||||
The package must be a project package (a workflow package is rejected with a 400). The
|
||||
cherry-pick conflict policies (folder, tag, project, and deletion mode) are fixed and are not
|
||||
accepted here; only `workflowConflictPolicy` and `workflowIdPolicy` are configurable. Maximum
|
||||
upload size is `N8N_ENDPOINTS_PAYLOAD_SIZE_MAX` MB (default 16). The caller is authorised
|
||||
through the `workflow:import` scope.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- package
|
||||
- selectedProjectId
|
||||
- selectedWorkflowIds
|
||||
properties:
|
||||
package:
|
||||
type: string
|
||||
format: binary
|
||||
description: Gzip-compressed tar package (`.n8np`).
|
||||
selectedProjectId:
|
||||
type: string
|
||||
description: >
|
||||
Id of the single source project the selection is scoped to, as it appears in the
|
||||
package.
|
||||
selectedWorkflowIds:
|
||||
type: string
|
||||
description: >
|
||||
JSON-encoded array of source workflow ids to import from `selectedProjectId`, e.g.
|
||||
`["wf-1","wf-2"]`. An empty array (`[]`) is allowed — useful for a delete-only
|
||||
request. Ids outside the scoped project are dropped.
|
||||
deletedWorkflowIds:
|
||||
type: string
|
||||
description: >
|
||||
Optional JSON-encoded array of destination workflow ids to remove from the scoped
|
||||
project, e.g. `["wf-3"]`. Removal happens even under the additive profile. An
|
||||
already-archived or absent id is a no-op unless it is also a selected destination.
|
||||
Must not contain a selected workflow's destination id. Needs the `workflow:delete` scope.
|
||||
workflowConflictPolicy:
|
||||
type: string
|
||||
enum:
|
||||
- new-version
|
||||
- fail
|
||||
- skip
|
||||
default: new-version
|
||||
description: >
|
||||
What happens when a selected workflow matches an existing workflow by source id in
|
||||
the target project. `new-version` (default) updates it and creates a new version;
|
||||
`fail` rejects the import when any matching workflow exists; `skip` leaves matching
|
||||
workflows unchanged. An archive state change also needs the `workflow:delete` scope.
|
||||
workflowIdPolicy:
|
||||
type: string
|
||||
enum:
|
||||
- new
|
||||
- source
|
||||
default: source
|
||||
description: >
|
||||
Controls the id each newly created workflow receives. `source` (default) reuses the
|
||||
package's own workflow id on the target instance; `new` mints a fresh id and records
|
||||
the package id as `sourceWorkflowId`. Workflows matched to an existing workflow keep
|
||||
that workflow's current id, regardless of policy.
|
||||
responses:
|
||||
'200':
|
||||
description: Import succeeded; the selected workflows were written to the target project.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/importResult.yml'
|
||||
'409':
|
||||
description: >
|
||||
Import blocked by at least one conflict among the issues — for example a workflow source-id
|
||||
conflict under `workflowConflictPolicy=fail`, or a selected destination also named for
|
||||
deletion (`workflow-removal-conflict`).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- message
|
||||
- issues
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
issues:
|
||||
type: array
|
||||
items:
|
||||
$ref: '../schemas/importBlockingIssue.yml'
|
||||
'422':
|
||||
description: >
|
||||
Import blocked by non-conflict issues only — for example a delete the caller may not
|
||||
perform (`workflow-removal-forbidden`), unresolved credentials, or node types this instance
|
||||
does not have.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- message
|
||||
- issues
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
issues:
|
||||
type: array
|
||||
items:
|
||||
$ref: '../schemas/importBlockingIssue.yml'
|
||||
'400':
|
||||
$ref: '../../../../shared/spec/responses/badRequest.yml'
|
||||
'401':
|
||||
$ref: '../../../../shared/spec/responses/unauthorized.yml'
|
||||
'403':
|
||||
$ref: '../../../../shared/spec/responses/forbidden.yml'
|
||||
'404':
|
||||
$ref: '../../../../shared/spec/responses/notFound.yml'
|
||||
|
|
@ -372,419 +372,7 @@ post:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- package
|
||||
- workflows
|
||||
- removedWorkflows
|
||||
- removedFolders
|
||||
- folders
|
||||
- projects
|
||||
- bindings
|
||||
- credentials
|
||||
- dataTables
|
||||
- variables
|
||||
- tags
|
||||
properties:
|
||||
package:
|
||||
type: object
|
||||
required:
|
||||
- sourceN8nVersion
|
||||
- sourceId
|
||||
- exportedAt
|
||||
properties:
|
||||
sourceN8nVersion:
|
||||
type: string
|
||||
sourceId:
|
||||
type: string
|
||||
exportedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
workflows:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- sourceWorkflowId
|
||||
- localId
|
||||
- name
|
||||
- projectId
|
||||
- parentFolderId
|
||||
- activeVersionId
|
||||
- isArchived
|
||||
- publishing
|
||||
- status
|
||||
properties:
|
||||
sourceWorkflowId:
|
||||
type: string
|
||||
description: Workflow id as it appeared in the package.
|
||||
localId:
|
||||
type: string
|
||||
description: >
|
||||
Workflow id on the target instance: newly assigned for
|
||||
`created` (fresh under `workflowIdPolicy=new`, the
|
||||
package id under `source`), the existing workflow's id
|
||||
for `updated`/`skipped`.
|
||||
name:
|
||||
type: string
|
||||
projectId:
|
||||
type: string
|
||||
parentFolderId:
|
||||
type: string
|
||||
nullable: true
|
||||
activeVersionId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >
|
||||
Published version on the target instance, if any. `null` when the
|
||||
workflow is not published after import.
|
||||
isArchived:
|
||||
type: boolean
|
||||
description: >
|
||||
Whether the workflow is archived on the target after import. Under
|
||||
`new-version` this follows the package's `isArchived` flag. A skipped
|
||||
workflow keeps its own state.
|
||||
publishing:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
description: >
|
||||
Outcome of applying the selected publishing policy to this workflow.
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
enum:
|
||||
- published
|
||||
- unpublished
|
||||
- unchanged
|
||||
- blocked
|
||||
- failed
|
||||
description: >
|
||||
`blocked` means the imported version could not be published
|
||||
and no version is active (for example because the workflow
|
||||
uses a stubbed credential, or uses a node type this instance
|
||||
does not have). When a prior published version
|
||||
remains active, `state` is `unchanged` with
|
||||
`skippedPublishReason` instead. `failed` means publish or
|
||||
unpublish was attempted but did not succeed.
|
||||
error:
|
||||
type: string
|
||||
description: >
|
||||
Present when `state` is `failed`. Explains why publish or unpublish
|
||||
could not be applied.
|
||||
blockedReason:
|
||||
type: string
|
||||
enum:
|
||||
- stub-credential
|
||||
- missing-node-type
|
||||
description: >
|
||||
Present when `state` is `blocked`: the imported version
|
||||
could not be published and no version is active.
|
||||
skippedPublishReason:
|
||||
type: string
|
||||
enum:
|
||||
- stub-credential
|
||||
- missing-node-type
|
||||
description: >
|
||||
Present when `state` is `unchanged` but the policy wanted
|
||||
to publish the imported version: a prior published version
|
||||
is still active (for example after an update with stubbed
|
||||
credentials).
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- created
|
||||
- updated
|
||||
- skipped
|
||||
description: Import outcome for this package workflow.
|
||||
removedWorkflows:
|
||||
type: array
|
||||
description: >
|
||||
Workflows the target had that the package does not, removed by
|
||||
`folderConflictPolicy=overwrite`. Empty under every other policy.
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- workflowId
|
||||
- name
|
||||
- projectId
|
||||
- parentFolderId
|
||||
- deletion
|
||||
properties:
|
||||
workflowId:
|
||||
type: string
|
||||
description: Id of the removed workflow on this instance.
|
||||
name:
|
||||
type: string
|
||||
projectId:
|
||||
type: string
|
||||
description: Project the workflow was reconciled against.
|
||||
parentFolderId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Folder that held the workflow, or null at the project root.
|
||||
deletion:
|
||||
type: string
|
||||
enum:
|
||||
- archived
|
||||
- deleted
|
||||
description: >
|
||||
What actually happened, not what `overwriteDeletionPolicy` asked
|
||||
for: a `hard-delete` whose row could not be dropped yet is
|
||||
reported as `archived`.
|
||||
removedFolders:
|
||||
type: array
|
||||
description: >
|
||||
Folders the package does not define that reconciliation left empty, removed by
|
||||
`folderConflictPolicy=overwrite`. A folder still holding anything survives, so
|
||||
target-only content is never swept up. Empty under every other policy.
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- folderId
|
||||
- name
|
||||
- projectId
|
||||
- parentFolderId
|
||||
properties:
|
||||
folderId:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
projectId:
|
||||
type: string
|
||||
parentFolderId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Folder that held it, or null at the project root.
|
||||
folders:
|
||||
type: array
|
||||
description: Folder shells created or skipped in the target project.
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- sourceFolderId
|
||||
- localId
|
||||
- name
|
||||
- parentFolderId
|
||||
- status
|
||||
properties:
|
||||
sourceFolderId:
|
||||
type: string
|
||||
description: Folder id as it appeared in the package.
|
||||
localId:
|
||||
type: string
|
||||
description: Folder id on the target instance (equal to `sourceFolderId`; folder ids are reused).
|
||||
name:
|
||||
type: string
|
||||
parentFolderId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Resolved parent folder on the target, or `null` at the project root.
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- created
|
||||
- skipped
|
||||
description: Import outcome for this package folder.
|
||||
projects:
|
||||
type: array
|
||||
description: >
|
||||
Project shells created, or matched and then updated or left as-is
|
||||
(see `projectConflictPolicy`). Present for project packages; empty
|
||||
when importing loose workflows/folders.
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- sourceProjectId
|
||||
- localId
|
||||
- name
|
||||
- status
|
||||
properties:
|
||||
sourceProjectId:
|
||||
type: string
|
||||
description: Project id as it appeared in the package.
|
||||
localId:
|
||||
type: string
|
||||
description: Project id on the target instance (equal to `sourceProjectId`; project ids are reused).
|
||||
name:
|
||||
type: string
|
||||
description: >
|
||||
The project's name on the target: the package's under
|
||||
`overwrite`, the pre-existing one under `merge`.
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- created
|
||||
- updated
|
||||
- skipped
|
||||
description: >
|
||||
Import outcome for this package project. `skipped` means the
|
||||
project already existed and `projectConflictPolicy=merge` left
|
||||
its details untouched — its contents were still imported.
|
||||
credentials:
|
||||
type: object
|
||||
description: >
|
||||
Source credential ids grouped by whether they matched an
|
||||
existing credential or were created as stubs. Full
|
||||
source→target id mapping is in `bindings.credentials`.
|
||||
required:
|
||||
- matched
|
||||
- stubbed
|
||||
properties:
|
||||
matched:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Source credential ids from the package that matched
|
||||
existing credentials on the target instance.
|
||||
stubbed:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Source credential ids for which empty placeholder
|
||||
credentials were created in the target project.
|
||||
dataTables:
|
||||
type: object
|
||||
required:
|
||||
- matched
|
||||
- created
|
||||
properties:
|
||||
matched:
|
||||
type: integer
|
||||
minimum: 0
|
||||
created:
|
||||
type: integer
|
||||
minimum: 0
|
||||
variables:
|
||||
type: object
|
||||
description: >
|
||||
Resolution of the package's variable requirements. Names only —
|
||||
values never travel in the response. For project packages these
|
||||
arrays are package-level unions of per-destination outcomes and may
|
||||
overlap (for example, a name may be created with a value in one project
|
||||
and stubbed in another); classification under concurrent
|
||||
external writes is best-effort.
|
||||
required:
|
||||
- matched
|
||||
- missing
|
||||
- created
|
||||
- stubbed
|
||||
- updated
|
||||
properties:
|
||||
matched:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Variable names that resolved to an existing variable
|
||||
(importing project's scope first, then global) and were
|
||||
left untouched. A resolved variable this import rewrote is
|
||||
listed under `updated` instead.
|
||||
missing:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Variable names still unresolved after import. Under
|
||||
`variableMissingMode=do-nothing` these are warnings — the import
|
||||
still succeeds and nothing is created. A successful `create-stub`
|
||||
or `create-with-value` import normally leaves this empty.
|
||||
created:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Variable names created with a package value under
|
||||
`variableMissingMode=create-with-value`. Empty stubs are listed
|
||||
under `stubbed`.
|
||||
stubbed:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Variable names created with an empty value by this import under
|
||||
`variableMissingMode=create-stub`, or because `create-with-value`
|
||||
had no exported value. Empty for other outcomes.
|
||||
updated:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Variable names whose existing value this import replaced with
|
||||
the package value under `variableConflictPolicy=overwrite`.
|
||||
Empty for other policies.
|
||||
tags:
|
||||
type: object
|
||||
description: >
|
||||
Resolution of the tags referenced by the imported workflows,
|
||||
matched by source id. Tag names only.
|
||||
required:
|
||||
- matched
|
||||
- created
|
||||
- renamed
|
||||
- reconciled
|
||||
- skipped
|
||||
properties:
|
||||
matched:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Tags that resolved to an existing tag with the same id and
|
||||
name; attached as-is.
|
||||
created:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Tags created by this import with their package (source) id
|
||||
and name, under `tagMissingMode=create`.
|
||||
renamed:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Target tags renamed to the package name under
|
||||
`tagConflictPolicy=rename`; listed by their new name.
|
||||
reconciled:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Existing target tags re-keyed to the package (source) id on
|
||||
a name collision under `tagConflictPolicy=rename`; their
|
||||
name, workflow and folder taggings are kept.
|
||||
skipped:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Tags dropped from the import — not created, not renamed, and
|
||||
not attached to any imported workflow — under
|
||||
`tagMissingMode=do-nothing` or `tagConflictPolicy=skip`.
|
||||
bindings:
|
||||
type: object
|
||||
description: >
|
||||
Source id → target id mappings produced during import, one map per
|
||||
entity type. Each value maps an id as it appeared in the package to
|
||||
the id on the target instance.
|
||||
required:
|
||||
- workflows
|
||||
- credentials
|
||||
properties:
|
||||
workflows:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
credentials:
|
||||
type: object
|
||||
description: >
|
||||
Credential id from `requirements.credentials` in the package
|
||||
manifest → matched credential id on the target instance.
|
||||
additionalProperties:
|
||||
type: string
|
||||
$ref: '../schemas/importResult.yml'
|
||||
'409':
|
||||
description: >
|
||||
Import blocked by at least one conflict among the issues — a workflow
|
||||
|
|
|
|||
|
|
@ -1,4 +1,25 @@
|
|||
oneOf:
|
||||
- type: object
|
||||
description: >
|
||||
A selected workflow resolves to a destination id in `deletedWorkflowIds`.
|
||||
The import stops before any writes, including when the conflict policy is `skip`.
|
||||
required:
|
||||
- type
|
||||
- sourceWorkflowId
|
||||
- workflowId
|
||||
- projectId
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
- workflow-removal-conflict
|
||||
sourceWorkflowId:
|
||||
type: string
|
||||
workflowId:
|
||||
type: string
|
||||
description: The destination id of the selected workflow.
|
||||
projectId:
|
||||
type: string
|
||||
- type: object
|
||||
description: >
|
||||
A workflow whose source id already matches one in the target project, under
|
||||
|
|
@ -181,10 +202,11 @@ oneOf:
|
|||
description: 'For `id-in-other-project`: the project that already owns the id.'
|
||||
- type: object
|
||||
description: >
|
||||
A workflow that `folderConflictPolicy=overwrite` would remove, because the package
|
||||
does not contain it, but the caller lacks `workflow:delete` on. Reported instead of
|
||||
removing a subset, which would leave the project matching neither the package nor
|
||||
its previous state.
|
||||
A workflow the import would remove but the caller lacks `workflow:delete` on:
|
||||
either `folderConflictPolicy=overwrite` would remove it because the package does
|
||||
not contain it, or it is named in the selection's `deletedWorkflowIds`. Reported
|
||||
instead of removing a subset, which would leave the project matching neither the
|
||||
package nor its previous state.
|
||||
required:
|
||||
- type
|
||||
- workflowId
|
||||
|
|
|
|||
|
|
@ -0,0 +1,412 @@
|
|||
# Shared 200 response body for import and import-selection endpoints (see n8n-packages.import*.yml).
|
||||
type: object
|
||||
required:
|
||||
- package
|
||||
- workflows
|
||||
- removedWorkflows
|
||||
- removedFolders
|
||||
- folders
|
||||
- projects
|
||||
- bindings
|
||||
- credentials
|
||||
- dataTables
|
||||
- variables
|
||||
- tags
|
||||
properties:
|
||||
package:
|
||||
type: object
|
||||
required:
|
||||
- sourceN8nVersion
|
||||
- sourceId
|
||||
- exportedAt
|
||||
properties:
|
||||
sourceN8nVersion:
|
||||
type: string
|
||||
sourceId:
|
||||
type: string
|
||||
exportedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
workflows:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- sourceWorkflowId
|
||||
- localId
|
||||
- name
|
||||
- projectId
|
||||
- parentFolderId
|
||||
- activeVersionId
|
||||
- isArchived
|
||||
- publishing
|
||||
- status
|
||||
properties:
|
||||
sourceWorkflowId:
|
||||
type: string
|
||||
description: Workflow id as it appeared in the package.
|
||||
localId:
|
||||
type: string
|
||||
description: >
|
||||
Workflow id on the target instance: newly assigned for
|
||||
`created` (fresh under `workflowIdPolicy=new`, the
|
||||
package id under `source`), the existing workflow's id
|
||||
for `updated`/`skipped`.
|
||||
name:
|
||||
type: string
|
||||
projectId:
|
||||
type: string
|
||||
parentFolderId:
|
||||
type: string
|
||||
nullable: true
|
||||
activeVersionId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: >
|
||||
Published version on the target instance, if any. `null` when the
|
||||
workflow is not published after import.
|
||||
isArchived:
|
||||
type: boolean
|
||||
description: >
|
||||
Whether the workflow is archived on the target after import. Under
|
||||
`new-version` this follows the package's `isArchived` flag. A skipped
|
||||
workflow keeps its own state.
|
||||
publishing:
|
||||
type: object
|
||||
required:
|
||||
- state
|
||||
description: >
|
||||
Outcome of applying the selected publishing policy to this workflow.
|
||||
properties:
|
||||
state:
|
||||
type: string
|
||||
enum:
|
||||
- published
|
||||
- unpublished
|
||||
- unchanged
|
||||
- blocked
|
||||
- failed
|
||||
description: >
|
||||
`blocked` means the imported version could not be published
|
||||
and no version is active (for example because the workflow
|
||||
uses a stubbed credential, or uses a node type this instance
|
||||
does not have). When a prior published version
|
||||
remains active, `state` is `unchanged` with
|
||||
`skippedPublishReason` instead. `failed` means publish or
|
||||
unpublish was attempted but did not succeed.
|
||||
error:
|
||||
type: string
|
||||
description: >
|
||||
Present when `state` is `failed`. Explains why publish or unpublish
|
||||
could not be applied.
|
||||
blockedReason:
|
||||
type: string
|
||||
enum:
|
||||
- stub-credential
|
||||
- missing-node-type
|
||||
description: >
|
||||
Present when `state` is `blocked`: the imported version
|
||||
could not be published and no version is active.
|
||||
skippedPublishReason:
|
||||
type: string
|
||||
enum:
|
||||
- stub-credential
|
||||
- missing-node-type
|
||||
description: >
|
||||
Present when `state` is `unchanged` but the policy wanted
|
||||
to publish the imported version: a prior published version
|
||||
is still active (for example after an update with stubbed
|
||||
credentials).
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- created
|
||||
- updated
|
||||
- skipped
|
||||
description: Import outcome for this package workflow.
|
||||
removedWorkflows:
|
||||
type: array
|
||||
description: Workflows removed from the target during this import.
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- workflowId
|
||||
- name
|
||||
- projectId
|
||||
- parentFolderId
|
||||
- deletion
|
||||
properties:
|
||||
workflowId:
|
||||
type: string
|
||||
description: Id of the removed workflow on this instance.
|
||||
name:
|
||||
type: string
|
||||
projectId:
|
||||
type: string
|
||||
description: Project the workflow was reconciled against.
|
||||
parentFolderId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Folder that held the workflow, or null at the project root.
|
||||
deletion:
|
||||
type: string
|
||||
enum:
|
||||
- archived
|
||||
- deleted
|
||||
description: >
|
||||
What actually happened, not what `overwriteDeletionPolicy` asked
|
||||
for: a `hard-delete` whose row could not be dropped yet is
|
||||
reported as `archived`.
|
||||
removedFolders:
|
||||
type: array
|
||||
description: >
|
||||
Folders the package does not define that reconciliation left empty, removed by
|
||||
`folderConflictPolicy=overwrite`. A folder still holding anything survives, so
|
||||
target-only content is never swept up. Empty under every other policy.
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- folderId
|
||||
- name
|
||||
- projectId
|
||||
- parentFolderId
|
||||
properties:
|
||||
folderId:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
projectId:
|
||||
type: string
|
||||
parentFolderId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Folder that held it, or null at the project root.
|
||||
folders:
|
||||
type: array
|
||||
description: Folder shells created or skipped in the target project.
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- sourceFolderId
|
||||
- localId
|
||||
- name
|
||||
- parentFolderId
|
||||
- status
|
||||
properties:
|
||||
sourceFolderId:
|
||||
type: string
|
||||
description: Folder id as it appeared in the package.
|
||||
localId:
|
||||
type: string
|
||||
description: Folder id on the target instance (equal to `sourceFolderId`; folder ids are reused).
|
||||
name:
|
||||
type: string
|
||||
parentFolderId:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Resolved parent folder on the target, or `null` at the project root.
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- created
|
||||
- skipped
|
||||
description: Import outcome for this package folder.
|
||||
projects:
|
||||
type: array
|
||||
description: >
|
||||
Project shells created, or matched and then updated or left as-is
|
||||
(see `projectConflictPolicy`). Present for project packages; empty
|
||||
when importing loose workflows/folders.
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- sourceProjectId
|
||||
- localId
|
||||
- name
|
||||
- status
|
||||
properties:
|
||||
sourceProjectId:
|
||||
type: string
|
||||
description: Project id as it appeared in the package.
|
||||
localId:
|
||||
type: string
|
||||
description: Project id on the target instance (equal to `sourceProjectId`; project ids are reused).
|
||||
name:
|
||||
type: string
|
||||
description: >
|
||||
The project's name on the target: the package's under
|
||||
`overwrite`, the pre-existing one under `merge`.
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- created
|
||||
- updated
|
||||
- skipped
|
||||
description: >
|
||||
Import outcome for this package project. `skipped` means the
|
||||
project already existed and `projectConflictPolicy=merge` left
|
||||
its details untouched — its contents were still imported.
|
||||
credentials:
|
||||
type: object
|
||||
description: >
|
||||
Source credential ids grouped by whether they matched an
|
||||
existing credential or were created as stubs. Full
|
||||
source→target id mapping is in `bindings.credentials`.
|
||||
required:
|
||||
- matched
|
||||
- stubbed
|
||||
properties:
|
||||
matched:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Source credential ids from the package that matched
|
||||
existing credentials on the target instance.
|
||||
stubbed:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Source credential ids for which empty placeholder
|
||||
credentials were created in the target project.
|
||||
dataTables:
|
||||
type: object
|
||||
required:
|
||||
- matched
|
||||
- created
|
||||
properties:
|
||||
matched:
|
||||
type: integer
|
||||
minimum: 0
|
||||
created:
|
||||
type: integer
|
||||
minimum: 0
|
||||
variables:
|
||||
type: object
|
||||
description: >
|
||||
Resolution of the package's variable requirements. Names only —
|
||||
values never travel in the response. For project packages these
|
||||
arrays are package-level unions of per-destination outcomes and may
|
||||
overlap (for example, a name may be created with a value in one project
|
||||
and stubbed in another); classification under concurrent
|
||||
external writes is best-effort.
|
||||
required:
|
||||
- matched
|
||||
- missing
|
||||
- created
|
||||
- stubbed
|
||||
- updated
|
||||
properties:
|
||||
matched:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Variable names that resolved to an existing variable
|
||||
(importing project's scope first, then global) and were
|
||||
left untouched. A resolved variable this import rewrote is
|
||||
listed under `updated` instead.
|
||||
missing:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Variable names still unresolved after import. Under
|
||||
`variableMissingMode=do-nothing` these are warnings — the import
|
||||
still succeeds and nothing is created. A successful `create-stub`
|
||||
or `create-with-value` import normally leaves this empty.
|
||||
created:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Variable names created with a package value under
|
||||
`variableMissingMode=create-with-value`. Empty stubs are listed
|
||||
under `stubbed`.
|
||||
stubbed:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Variable names created with an empty value by this import under
|
||||
`variableMissingMode=create-stub`, or because `create-with-value`
|
||||
had no exported value. Empty for other outcomes.
|
||||
updated:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Variable names whose existing value this import replaced with
|
||||
the package value under `variableConflictPolicy=overwrite`.
|
||||
Empty for other policies.
|
||||
tags:
|
||||
type: object
|
||||
description: >
|
||||
Resolution of the tags referenced by the imported workflows,
|
||||
matched by source id. Tag names only.
|
||||
required:
|
||||
- matched
|
||||
- created
|
||||
- renamed
|
||||
- reconciled
|
||||
- skipped
|
||||
properties:
|
||||
matched:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Tags that resolved to an existing tag with the same id and
|
||||
name; attached as-is.
|
||||
created:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Tags created by this import with their package (source) id
|
||||
and name, under `tagMissingMode=create`.
|
||||
renamed:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Target tags renamed to the package name under
|
||||
`tagConflictPolicy=rename`; listed by their new name.
|
||||
reconciled:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Existing target tags re-keyed to the package (source) id on
|
||||
a name collision under `tagConflictPolicy=rename`; their
|
||||
name, workflow and folder taggings are kept.
|
||||
skipped:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
Tags dropped from the import — not created, not renamed, and
|
||||
not attached to any imported workflow — under
|
||||
`tagMissingMode=do-nothing` or `tagConflictPolicy=skip`.
|
||||
bindings:
|
||||
type: object
|
||||
description: >
|
||||
Source id → target id mappings produced during import, one map per
|
||||
entity type. Each value maps an id as it appeared in the package to
|
||||
the id on the target instance.
|
||||
required:
|
||||
- workflows
|
||||
- credentials
|
||||
properties:
|
||||
workflows:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
credentials:
|
||||
type: object
|
||||
description: >
|
||||
Credential id from `requirements.credentials` in the package
|
||||
manifest → matched credential id on the target instance.
|
||||
additionalProperties:
|
||||
type: string
|
||||
|
|
@ -123,6 +123,8 @@ paths:
|
|||
$ref: './handlers/n8n-packages/spec/paths/n8n-packages.export.yml'
|
||||
/n8n-packages/import:
|
||||
$ref: './handlers/n8n-packages/spec/paths/n8n-packages.import.yml'
|
||||
/n8n-packages/import-selection:
|
||||
$ref: './handlers/n8n-packages/spec/paths/n8n-packages.import-selection.yml'
|
||||
/settings/log-streaming/event-types:
|
||||
$ref: './handlers/log-streaming/spec/paths/event-types.yml'
|
||||
/settings/log-streaming/destinations:
|
||||
|
|
|
|||
|
|
@ -260,6 +260,13 @@ export class WorkflowFinderService {
|
|||
return byFolder;
|
||||
}
|
||||
|
||||
async findOwnedWorkflowRemovalCandidates(projectId: string, workflowIds: string[]) {
|
||||
return await this.sharedWorkflowRepository.findOwnedWorkflowRemovalCandidates(
|
||||
projectId,
|
||||
workflowIds,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflows a project owns, each paired with the folder holding it (`null` at the
|
||||
* project root). Archived workflows are excluded unless asked for; a reconciling
|
||||
|
|
@ -267,8 +274,7 @@ export class WorkflowFinderService {
|
|||
*
|
||||
* The widest of the three scope lookups here — use {@link findRootWorkflowIdsInProject} when
|
||||
* only the project root matters, or {@link findWorkflowIdsByFolder} for folders that need not
|
||||
* belong to one project. This one is for reconciling a whole project, so it is the only one
|
||||
* that carries names and filters archived rows.
|
||||
* belong to one project. For explicit removals, use {@link findOwnedWorkflowRemovalCandidates}.
|
||||
*/
|
||||
async findOwnedWorkflowPlacementsInProject(
|
||||
projectId: string,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,272 @@
|
|||
import {
|
||||
createTeamProject,
|
||||
createWorkflow,
|
||||
linkUserToProject,
|
||||
mockInstance,
|
||||
testDb,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import { WorkflowRepository, type User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
|
||||
import { EventService } from '@/events/event.service';
|
||||
import {
|
||||
buildEntityPackageBuffer,
|
||||
serializedProject,
|
||||
serializedWorkflow,
|
||||
} from '@/modules/n8n-packages/__tests__/fixtures/package-fixtures';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
|
||||
import { createCustomRoleWithScopeSlugs } from '../shared/db/roles';
|
||||
import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
|
||||
import type { SuperAgentTest } from '../shared/types';
|
||||
import * as utils from '../shared/utils/';
|
||||
|
||||
mockInstance(Telemetry);
|
||||
|
||||
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
|
||||
|
||||
let owner: User;
|
||||
let authOwnerAgent: SuperAgentTest;
|
||||
|
||||
async function buildProjectPackage(projectId: string): Promise<Buffer> {
|
||||
return await buildEntityPackageBuffer({
|
||||
sourceId: 'http-selection-source',
|
||||
projects: [
|
||||
{ target: 'projects/p1', project: serializedProject({ id: projectId, name: 'P1' }) },
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
target: 'projects/p1/workflows/wfa',
|
||||
workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }),
|
||||
},
|
||||
{
|
||||
target: 'projects/p1/workflows/wfb',
|
||||
workflow: serializedWorkflow({ id: 'WFB', name: 'wfb' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.initNodeTypes();
|
||||
owner = await createOwnerWithApiKey();
|
||||
Container.get(InstanceSettings).markAsLeader();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate([
|
||||
'WorkflowEntity',
|
||||
'SharedWorkflow',
|
||||
'Project',
|
||||
'ProjectRelation',
|
||||
'Folder',
|
||||
]);
|
||||
testServer.license.enable('feat:projectRole:admin');
|
||||
testServer.license.enable('feat:folders');
|
||||
testServer.license.setQuota('quota:maxTeamProjects', 100);
|
||||
authOwnerAgent = testServer.publicApiAgentFor(owner);
|
||||
});
|
||||
|
||||
describe('POST /n8n-packages/import-selection', () => {
|
||||
it('returns 409 before writing a workflow selected for import and deletion', async () => {
|
||||
const project = await createTeamProject('Target', owner);
|
||||
const tarBuffer = await buildProjectPackage(project.id);
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.post('/n8n-packages/import-selection')
|
||||
.field('selectedProjectId', project.id)
|
||||
.field('selectedWorkflowIds', JSON.stringify(['WFA', 'WFB']))
|
||||
.field('deletedWorkflowIds', JSON.stringify(['WFA']))
|
||||
.attach('package', tarBuffer, 'import.n8np');
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.body.issues).toEqual([
|
||||
{
|
||||
type: 'workflow-removal-conflict',
|
||||
sourceWorkflowId: 'WFA',
|
||||
workflowId: 'WFA',
|
||||
projectId: project.id,
|
||||
},
|
||||
]);
|
||||
expect(await Container.get(WorkflowRepository).count()).toBe(0);
|
||||
});
|
||||
|
||||
it('imports only the selected subset into the target project', async () => {
|
||||
const project = await createTeamProject('Target', owner);
|
||||
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
|
||||
const tarBuffer = await buildProjectPackage(project.id);
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.post('/n8n-packages/import-selection')
|
||||
.field('selectedProjectId', project.id)
|
||||
.field('selectedWorkflowIds', JSON.stringify(['WFA']))
|
||||
.attach('package', tarBuffer, 'import.n8np');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.workflows).toHaveLength(1);
|
||||
expect(response.body.workflows[0]).toMatchObject({
|
||||
sourceWorkflowId: 'WFA',
|
||||
status: 'created',
|
||||
projectId: project.id,
|
||||
});
|
||||
expect(emitSpy).toHaveBeenCalledWith('n8n-package-imported', expect.any(Object));
|
||||
|
||||
// Ground truth: only WFA lands in the DB; the unselected WFB is never created.
|
||||
const workflowRepository = Container.get(WorkflowRepository);
|
||||
expect(await workflowRepository.count()).toBe(1);
|
||||
expect(await workflowRepository.findOneBy({ id: 'WFA' })).not.toBeNull();
|
||||
expect(await workflowRepository.findOneBy({ id: 'WFB' })).toBeNull();
|
||||
});
|
||||
|
||||
it('archives a target workflow named in deletedWorkflowIds and returns 200', async () => {
|
||||
const project = await createTeamProject('Target', owner);
|
||||
const victim = await createWorkflow({ name: 'Victim' }, project);
|
||||
const tarBuffer = await buildProjectPackage(project.id);
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.post('/n8n-packages/import-selection')
|
||||
.field('selectedProjectId', project.id)
|
||||
.field('selectedWorkflowIds', JSON.stringify(['WFA']))
|
||||
.field('deletedWorkflowIds', JSON.stringify([victim.id]))
|
||||
.attach('package', tarBuffer, 'import.n8np');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.workflows).toHaveLength(1);
|
||||
expect(response.body.workflows[0]).toMatchObject({
|
||||
sourceWorkflowId: 'WFA',
|
||||
status: 'created',
|
||||
});
|
||||
expect(response.body.removedWorkflows).toEqual([
|
||||
expect.objectContaining({ workflowId: victim.id, deletion: 'archived' }),
|
||||
]);
|
||||
|
||||
const workflowRepository = Container.get(WorkflowRepository);
|
||||
expect((await workflowRepository.findOneBy({ id: victim.id }))?.isArchived).toBe(true);
|
||||
expect(await workflowRepository.findOneBy({ id: 'WFA' })).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns 409 for a conflicting import under workflowConflictPolicy=fail', async () => {
|
||||
const project = await createTeamProject('Target', owner);
|
||||
const tarBuffer = await buildProjectPackage(project.id);
|
||||
|
||||
// Seed WFA in the target under the default policy.
|
||||
await authOwnerAgent
|
||||
.post('/n8n-packages/import-selection')
|
||||
.field('selectedProjectId', project.id)
|
||||
.field('selectedWorkflowIds', JSON.stringify(['WFA']))
|
||||
.attach('package', tarBuffer, 'import.n8np');
|
||||
|
||||
// Re-importing the same id under `fail` is blocked as a conflict.
|
||||
const response = await authOwnerAgent
|
||||
.post('/n8n-packages/import-selection')
|
||||
.field('selectedProjectId', project.id)
|
||||
.field('selectedWorkflowIds', JSON.stringify(['WFA']))
|
||||
.field('workflowConflictPolicy', 'fail')
|
||||
.attach('package', tarBuffer, 'import.n8np');
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.body.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'workflow-conflict',
|
||||
sourceWorkflowId: 'WFA',
|
||||
existingWorkflowId: 'WFA',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a request that omits selectedProjectId', async () => {
|
||||
const project = await createTeamProject('Target', owner);
|
||||
const tarBuffer = await buildProjectPackage(project.id);
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.post('/n8n-packages/import-selection')
|
||||
.field('selectedWorkflowIds', JSON.stringify(['WFA']))
|
||||
.attach('package', tarBuffer, 'import.n8np');
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a blank selectedProjectId', async () => {
|
||||
const project = await createTeamProject('Target', owner);
|
||||
const tarBuffer = await buildProjectPackage(project.id);
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.post('/n8n-packages/import-selection')
|
||||
.field('selectedProjectId', '')
|
||||
.field('selectedWorkflowIds', JSON.stringify(['WFA']))
|
||||
.attach('package', tarBuffer, 'import.n8np');
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a non-project (workflow) package', async () => {
|
||||
const tarBuffer = await buildEntityPackageBuffer({
|
||||
sourceId: 'http-selection-workflow-package',
|
||||
workflows: [
|
||||
{ target: 'workflows/wfa', workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.post('/n8n-packages/import-selection')
|
||||
.field('selectedProjectId', 'P1')
|
||||
.field('selectedWorkflowIds', JSON.stringify(['WFA']))
|
||||
.attach('package', tarBuffer, 'import.n8np');
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 422 when the caller may not perform a requested delete', async () => {
|
||||
const project = await createTeamProject('Target', owner);
|
||||
const protectedWorkflow = await createWorkflow({ name: 'Protected' }, project);
|
||||
|
||||
// Grant the API key delete scope so the request reaches the project permission check.
|
||||
// The project role must cause the 422 response.
|
||||
const member = await createMemberWithApiKey({
|
||||
scopes: ['project:create', 'project:update', 'workflow:import', 'workflow:delete'],
|
||||
});
|
||||
const importOnlyRole = await createCustomRoleWithScopeSlugs(
|
||||
[
|
||||
'project:read',
|
||||
'project:list',
|
||||
'project:update',
|
||||
'workflow:create',
|
||||
'workflow:read',
|
||||
'workflow:update',
|
||||
'workflow:import',
|
||||
'workflow:list',
|
||||
'workflow:publish',
|
||||
'folder:create',
|
||||
'folder:read',
|
||||
'folder:update',
|
||||
'folder:list',
|
||||
'credential:read',
|
||||
'credential:list',
|
||||
],
|
||||
{ roleType: 'project' },
|
||||
);
|
||||
await linkUserToProject(member, project, importOnlyRole.slug);
|
||||
|
||||
const tarBuffer = await buildProjectPackage(project.id);
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(member)
|
||||
.post('/n8n-packages/import-selection')
|
||||
.field('selectedProjectId', project.id)
|
||||
.field('selectedWorkflowIds', JSON.stringify(['WFA']))
|
||||
.field('deletedWorkflowIds', JSON.stringify([protectedWorkflow.id]))
|
||||
.attach('package', tarBuffer, 'import.n8np');
|
||||
|
||||
expect(response.statusCode).toBe(422);
|
||||
expect(response.body).toMatchObject({
|
||||
message: expect.stringContaining('Import blocked'),
|
||||
issues: [
|
||||
expect.objectContaining({
|
||||
type: 'workflow-removal-forbidden',
|
||||
workflowId: protectedWorkflow.id,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,16 @@
|
|||
import { createActiveWorkflow, createWorkflow, testDb } from '@n8n/backend-test-utils';
|
||||
import {
|
||||
createActiveWorkflow,
|
||||
createTeamProject,
|
||||
createWorkflow,
|
||||
shareWorkflowWithProjects,
|
||||
testDb,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, type User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
import { createFolder } from '../shared/db/folders';
|
||||
import { createUser } from '../shared/db/users';
|
||||
|
||||
let owner: User;
|
||||
|
|
@ -20,7 +27,7 @@ beforeAll(async () => {
|
|||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['WorkflowEntity', 'SharedWorkflow']);
|
||||
await testDb.truncate(['WorkflowEntity', 'SharedWorkflow', 'Folder']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
@ -28,6 +35,34 @@ afterAll(async () => {
|
|||
});
|
||||
|
||||
describe('WorkflowFinderService', () => {
|
||||
describe('findOwnedWorkflowRemovalCandidates', () => {
|
||||
it('returns only requested unarchived workflows owned by the project', async () => {
|
||||
const project = await createTeamProject('Target', owner);
|
||||
const otherProject = await createTeamProject('Other', owner);
|
||||
const folder = await createFolder(project);
|
||||
const root = await createWorkflow({ name: 'Root' }, project);
|
||||
const nested = await createWorkflow({ name: 'Nested', parentFolder: folder }, project);
|
||||
const archived = await createWorkflow({ isArchived: true }, project);
|
||||
await createWorkflow({ name: 'Unrequested' }, project);
|
||||
const other = await createWorkflow({}, otherProject);
|
||||
const shared = await createWorkflow({}, otherProject);
|
||||
await shareWorkflowWithProjects(shared, [{ project, role: 'workflow:editor' }]);
|
||||
|
||||
const candidates = await workflowFinderService.findOwnedWorkflowRemovalCandidates(
|
||||
project.id,
|
||||
[root.id, nested.id, archived.id, other.id, shared.id, 'missing', root.id],
|
||||
);
|
||||
|
||||
expect(candidates).toHaveLength(2);
|
||||
expect(candidates).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ id: root.id, name: 'Root', parentFolderId: null },
|
||||
{ id: nested.id, name: 'Nested', parentFolderId: folder.id },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findWorkflowHeadForUser', () => {
|
||||
it('should return the workflow head for a user with a project-scoped role', async () => {
|
||||
const workflow = await createWorkflow({}, member);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user