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:
Irénée 2026-09-25 11:17:09 +00:00 committed by GitHub
parent 8e065f9a78
commit 893e97c18e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 2708 additions and 465 deletions

View File

@ -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';

View File

@ -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',
]);
});
});

View File

@ -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'),
}) {}

View File

@ -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);

View File

@ -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.

View File

@ -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();
}

View File

@ -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);
});
});
});

View File

@ -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"');
});
});
});

View File

@ -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' ||

View File

@ -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 }),
),

View File

@ -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;
}

View File

@ -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: [],
});

View File

@ -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

View File

@ -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,

View File

@ -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(

View File

@ -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

View File

@ -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}"`);
}
}

View File

@ -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 {

View File

@ -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',
});
});
});
});

View File

@ -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;

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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,

View File

@ -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,
}),
],
});
});
});

View File

@ -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);