diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 9c85c19e935..9b87c532851 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/dto/packages/__tests__/import-package-selection-request.dto.test.ts b/packages/@n8n/api-types/src/dto/packages/__tests__/import-package-selection-request.dto.test.ts new file mode 100644 index 00000000000..0ed4896fac6 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/packages/__tests__/import-package-selection-request.dto.test.ts @@ -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', + ]); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/packages/import-package-request.dto.ts b/packages/@n8n/api-types/src/dto/packages/import-package-request.dto.ts index edc0f6fdba7..2d90b4f659f 100644 --- a/packages/@n8n/api-types/src/dto/packages/import-package-request.dto.ts +++ b/packages/@n8n/api-types/src/dto/packages/import-package-request.dto.ts @@ -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'), +}) {} diff --git a/packages/@n8n/db/src/repositories/__tests__/shared-workflow.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/shared-workflow.repository.test.ts index 19ee2af0ee1..ced665cfcf0 100644 --- a/packages/@n8n/db/src/repositories/__tests__/shared-workflow.repository.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/shared-workflow.repository.test.ts @@ -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({ + id: 'root', + name: 'Root workflow', + parentFolder: null, + }); + const folderWorkflow = mock({ + id: 'nested', + name: 'Nested workflow', + parentFolder: mock({ id: 'folder' }), + }); + const rootRow = mock({ workflow: rootWorkflow }); + const folderRow = mock({ 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); diff --git a/packages/@n8n/db/src/repositories/shared-workflow.repository.ts b/packages/@n8n/db/src/repositories/shared-workflow.repository.ts index 7c893f36284..bdd486d38d9 100644 --- a/packages/@n8n/db/src/repositories/shared-workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/shared-workflow.repository.ts @@ -25,6 +25,38 @@ export class SharedWorkflowRepository extends BaseRepository { super(SharedWorkflow, dataSource.manager, transactionRunner); } + async findOwnedWorkflowRemovalCandidates( + projectId: string, + workflowIds: string[], + ): Promise> { + 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. diff --git a/packages/cli/src/modules/n8n-packages/__tests__/fixtures/package-fixtures.ts b/packages/cli/src/modules/n8n-packages/__tests__/fixtures/package-fixtures.ts index d0e217c7ddf..b64193a465f 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/fixtures/package-fixtures.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/fixtures/package-fixtures.ts @@ -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; sourceId?: string; -}): Promise { - const writer = new TarPackageWriter(); +} + +async function writeEntityPackage( + writer: PackageWriter, + options: EntityPackageOptions, +): Promise { 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 { + 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 { + const writer = new DirectoryPackageWriter(targetDir); + await writeEntityPackage(writer, options); + await writer.finalize(); +} diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-package-selection.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-package-selection.integration.test.ts new file mode 100644 index 00000000000..3b2dfa08365 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-package-selection.integration.test.ts @@ -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 { + 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, + ) { + 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); + }); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/utils/import-package-upload.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/utils/import-package-upload.test.ts index f2a53f54480..aea7946b1af 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/utils/import-package-upload.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/utils/import-package-upload.test.ts @@ -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"'); + }); + }); }); diff --git a/packages/cli/src/modules/n8n-packages/engine/import-blocked.error.ts b/packages/cli/src/modules/n8n-packages/engine/import-blocked.error.ts index 49368f48234..8e1441c3f9c 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-blocked.error.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-blocked.error.ts @@ -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' || diff --git a/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts b/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts index b891060f524..325b2ca5765 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts @@ -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 }), ), diff --git a/packages/cli/src/modules/n8n-packages/engine/project-package-importer.ts b/packages/cli/src/modules/n8n-packages/engine/project-package-importer.ts index f4b87c7ce55..b2d392bb51e 100644 --- a/packages/cli/src/modules/n8n-packages/engine/project-package-importer.ts +++ b/packages/cli/src/modules/n8n-packages/engine/project-package-importer.ts @@ -19,6 +19,7 @@ import type { ImportBindingMap, ImportedFolderSummary, ImportedWorkflowSummary, + ImportSelection, ResolvedImportRequest, ImportTagSummary, PackageImportBindings, @@ -71,7 +72,15 @@ export class ProjectPackageImporter { ): Promise { 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 { 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; +} diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-remover.test.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-remover.test.ts index 7bce8d03e87..50ddac57252 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-remover.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-remover.test.ts @@ -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(); 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>({ + action, + decidedId: 'target', + sourceWorkflowId: 'source', + }) + : mock>({ + action, + existing: mock({ 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: [], }); diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-removal.types.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-removal.types.ts index 25439c934a2..e77ae99822d 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-removal.types.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-removal.types.ts @@ -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 diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-remover.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-remover.ts index 24bec61df15..d0f896f09e7 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-remover.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-remover.ts @@ -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 { + 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, diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts index 38bacd4d4f1..748405498ef 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts @@ -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 { + 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 { + 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 { + 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 { 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 { + 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( diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts index 77ab45f9c0f..778c85b2a32 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts @@ -255,6 +255,8 @@ export type ImportRequest = { folderId?: string; bindings?: Partial; 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; + 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 diff --git a/packages/cli/src/modules/n8n-packages/utils/import-package-upload.ts b/packages/cli/src/modules/n8n-packages/utils/import-package-upload.ts index 03b1fbf4629..452dfb78fa2 100644 --- a/packages/cli/src/modules/n8n-packages/utils/import-package-upload.ts +++ b/packages/cli/src/modules/n8n-packages/utils/import-package-upload.ts @@ -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([ +export const IMPORT_PACKAGE_BODY_FIELD_SET = new Set([ ...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([ + ...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; - body?: Record; -}): Express.Multer.File { +export function resolveImportPackageUpload( + req: { + files?: Express.Multer.File[] | Record; + body?: Record; + }, + allowedBodyFields: Set = 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}"`); } } diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index 17e1bf5f8dd..8bc93205976 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -53,6 +53,20 @@ export declare namespace PackageRequest { { projectId?: string; folderId?: string }, Record >; + + 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 + >; } export declare namespace UserRequest { diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts b/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts index 8e673e54439..0a3cd09675b 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts @@ -32,11 +32,13 @@ let handler: Record 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, + 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', + }); + }); + }); }); diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts b/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts index 13a2d0ed49f..08112ce007c 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts @@ -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; importPackage: PublicAPIEndpoint; + importPackageSelection: PublicAPIEndpoint; }; 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; diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.import-selection.yml b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.import-selection.yml new file mode 100644 index 00000000000..03faefbc48b --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.import-selection.yml @@ -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' diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.import.yml b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.import.yml index 63be82dac79..b0adee1ad93 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.import.yml +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.import.yml @@ -372,419 +372,7 @@ post: content: application/json: schema: - type: object - required: - - package - - workflows - - removedWorkflows - - removedFolders - - folders - - projects - - bindings - - credentials - - dataTables - - variables - - tags - properties: - package: - type: object - required: - - sourceN8nVersion - - sourceId - - exportedAt - properties: - sourceN8nVersion: - type: string - sourceId: - type: string - exportedAt: - type: string - format: date-time - workflows: - type: array - items: - type: object - required: - - sourceWorkflowId - - localId - - name - - projectId - - parentFolderId - - activeVersionId - - isArchived - - publishing - - status - properties: - sourceWorkflowId: - type: string - description: Workflow id as it appeared in the package. - localId: - type: string - description: > - Workflow id on the target instance: newly assigned for - `created` (fresh under `workflowIdPolicy=new`, the - package id under `source`), the existing workflow's id - for `updated`/`skipped`. - name: - type: string - projectId: - type: string - parentFolderId: - type: string - nullable: true - activeVersionId: - type: string - nullable: true - description: > - Published version on the target instance, if any. `null` when the - workflow is not published after import. - isArchived: - type: boolean - description: > - Whether the workflow is archived on the target after import. Under - `new-version` this follows the package's `isArchived` flag. A skipped - workflow keeps its own state. - publishing: - type: object - required: - - state - description: > - Outcome of applying the selected publishing policy to this workflow. - properties: - state: - type: string - enum: - - published - - unpublished - - unchanged - - blocked - - failed - description: > - `blocked` means the imported version could not be published - and no version is active (for example because the workflow - uses a stubbed credential, or uses a node type this instance - does not have). When a prior published version - remains active, `state` is `unchanged` with - `skippedPublishReason` instead. `failed` means publish or - unpublish was attempted but did not succeed. - error: - type: string - description: > - Present when `state` is `failed`. Explains why publish or unpublish - could not be applied. - blockedReason: - type: string - enum: - - stub-credential - - missing-node-type - description: > - Present when `state` is `blocked`: the imported version - could not be published and no version is active. - skippedPublishReason: - type: string - enum: - - stub-credential - - missing-node-type - description: > - Present when `state` is `unchanged` but the policy wanted - to publish the imported version: a prior published version - is still active (for example after an update with stubbed - credentials). - status: - type: string - enum: - - created - - updated - - skipped - description: Import outcome for this package workflow. - removedWorkflows: - type: array - description: > - Workflows the target had that the package does not, removed by - `folderConflictPolicy=overwrite`. Empty under every other policy. - items: - type: object - required: - - workflowId - - name - - projectId - - parentFolderId - - deletion - properties: - workflowId: - type: string - description: Id of the removed workflow on this instance. - name: - type: string - projectId: - type: string - description: Project the workflow was reconciled against. - parentFolderId: - type: string - nullable: true - description: Folder that held the workflow, or null at the project root. - deletion: - type: string - enum: - - archived - - deleted - description: > - What actually happened, not what `overwriteDeletionPolicy` asked - for: a `hard-delete` whose row could not be dropped yet is - reported as `archived`. - removedFolders: - type: array - description: > - Folders the package does not define that reconciliation left empty, removed by - `folderConflictPolicy=overwrite`. A folder still holding anything survives, so - target-only content is never swept up. Empty under every other policy. - items: - type: object - required: - - folderId - - name - - projectId - - parentFolderId - properties: - folderId: - type: string - name: - type: string - projectId: - type: string - parentFolderId: - type: string - nullable: true - description: Folder that held it, or null at the project root. - folders: - type: array - description: Folder shells created or skipped in the target project. - items: - type: object - required: - - sourceFolderId - - localId - - name - - parentFolderId - - status - properties: - sourceFolderId: - type: string - description: Folder id as it appeared in the package. - localId: - type: string - description: Folder id on the target instance (equal to `sourceFolderId`; folder ids are reused). - name: - type: string - parentFolderId: - type: string - nullable: true - description: Resolved parent folder on the target, or `null` at the project root. - status: - type: string - enum: - - created - - skipped - description: Import outcome for this package folder. - projects: - type: array - description: > - Project shells created, or matched and then updated or left as-is - (see `projectConflictPolicy`). Present for project packages; empty - when importing loose workflows/folders. - items: - type: object - required: - - sourceProjectId - - localId - - name - - status - properties: - sourceProjectId: - type: string - description: Project id as it appeared in the package. - localId: - type: string - description: Project id on the target instance (equal to `sourceProjectId`; project ids are reused). - name: - type: string - description: > - The project's name on the target: the package's under - `overwrite`, the pre-existing one under `merge`. - status: - type: string - enum: - - created - - updated - - skipped - description: > - Import outcome for this package project. `skipped` means the - project already existed and `projectConflictPolicy=merge` left - its details untouched — its contents were still imported. - credentials: - type: object - description: > - Source credential ids grouped by whether they matched an - existing credential or were created as stubs. Full - source→target id mapping is in `bindings.credentials`. - required: - - matched - - stubbed - properties: - matched: - type: array - items: - type: string - description: > - Source credential ids from the package that matched - existing credentials on the target instance. - stubbed: - type: array - items: - type: string - description: > - Source credential ids for which empty placeholder - credentials were created in the target project. - dataTables: - type: object - required: - - matched - - created - properties: - matched: - type: integer - minimum: 0 - created: - type: integer - minimum: 0 - variables: - type: object - description: > - Resolution of the package's variable requirements. Names only — - values never travel in the response. For project packages these - arrays are package-level unions of per-destination outcomes and may - overlap (for example, a name may be created with a value in one project - and stubbed in another); classification under concurrent - external writes is best-effort. - required: - - matched - - missing - - created - - stubbed - - updated - properties: - matched: - type: array - items: - type: string - description: > - Variable names that resolved to an existing variable - (importing project's scope first, then global) and were - left untouched. A resolved variable this import rewrote is - listed under `updated` instead. - missing: - type: array - items: - type: string - description: > - Variable names still unresolved after import. Under - `variableMissingMode=do-nothing` these are warnings — the import - still succeeds and nothing is created. A successful `create-stub` - or `create-with-value` import normally leaves this empty. - created: - type: array - items: - type: string - description: > - Variable names created with a package value under - `variableMissingMode=create-with-value`. Empty stubs are listed - under `stubbed`. - stubbed: - type: array - items: - type: string - description: > - Variable names created with an empty value by this import under - `variableMissingMode=create-stub`, or because `create-with-value` - had no exported value. Empty for other outcomes. - updated: - type: array - items: - type: string - description: > - Variable names whose existing value this import replaced with - the package value under `variableConflictPolicy=overwrite`. - Empty for other policies. - tags: - type: object - description: > - Resolution of the tags referenced by the imported workflows, - matched by source id. Tag names only. - required: - - matched - - created - - renamed - - reconciled - - skipped - properties: - matched: - type: array - items: - type: string - description: > - Tags that resolved to an existing tag with the same id and - name; attached as-is. - created: - type: array - items: - type: string - description: > - Tags created by this import with their package (source) id - and name, under `tagMissingMode=create`. - renamed: - type: array - items: - type: string - description: > - Target tags renamed to the package name under - `tagConflictPolicy=rename`; listed by their new name. - reconciled: - type: array - items: - type: string - description: > - Existing target tags re-keyed to the package (source) id on - a name collision under `tagConflictPolicy=rename`; their - name, workflow and folder taggings are kept. - skipped: - type: array - items: - type: string - description: > - Tags dropped from the import — not created, not renamed, and - not attached to any imported workflow — under - `tagMissingMode=do-nothing` or `tagConflictPolicy=skip`. - bindings: - type: object - description: > - Source id → target id mappings produced during import, one map per - entity type. Each value maps an id as it appeared in the package to - the id on the target instance. - required: - - workflows - - credentials - properties: - workflows: - type: object - additionalProperties: - type: string - credentials: - type: object - description: > - Credential id from `requirements.credentials` in the package - manifest → matched credential id on the target instance. - additionalProperties: - type: string + $ref: '../schemas/importResult.yml' '409': description: > Import blocked by at least one conflict among the issues — a workflow diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/importBlockingIssue.yml b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/importBlockingIssue.yml index 73d56da469a..1e60ef8bbb0 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/importBlockingIssue.yml +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/importBlockingIssue.yml @@ -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 diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/importResult.yml b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/importResult.yml new file mode 100644 index 00000000000..46487f28a74 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/schemas/importResult.yml @@ -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 diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index ce16c5cdaea..062e0b6cbf0 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -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: diff --git a/packages/cli/src/workflows/workflow-finder.service.ts b/packages/cli/src/workflows/workflow-finder.service.ts index 9aedc35673c..0538624a9a8 100644 --- a/packages/cli/src/workflows/workflow-finder.service.ts +++ b/packages/cli/src/workflows/workflow-finder.service.ts @@ -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, diff --git a/packages/cli/test/integration/public-api/n8n-packages-import-selection.test.ts b/packages/cli/test/integration/public-api/n8n-packages-import-selection.test.ts new file mode 100644 index 00000000000..05ee18e326f --- /dev/null +++ b/packages/cli/test/integration/public-api/n8n-packages-import-selection.test.ts @@ -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 { + 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, + }), + ], + }); + }); +}); diff --git a/packages/cli/test/integration/workflows/workflow-finder.service.test.ts b/packages/cli/test/integration/workflows/workflow-finder.service.test.ts index 821a6befeb0..ea70722e81a 100644 --- a/packages/cli/test/integration/workflows/workflow-finder.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow-finder.service.test.ts @@ -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);