mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 03:24:59 +02:00
feat(core): Import a project with folders and workflows (no-changelog) (#34026)
This commit is contained in:
parent
f7f0556f65
commit
4844223dbc
|
|
@ -63,7 +63,7 @@ describe('LogStreamingEventRelay', () => {
|
|||
lastName: 'User',
|
||||
role: { slug: 'global:admin' },
|
||||
},
|
||||
projectId: 'proj-brie',
|
||||
projectIds: ['proj-brie', 'proj-stilton'],
|
||||
folderId: 'folder-cheese',
|
||||
workflowIds: ['wf-cheddar', 'wf-brie'],
|
||||
options: {
|
||||
|
|
@ -113,7 +113,7 @@ describe('LogStreamingEventRelay', () => {
|
|||
_firstName: 'Import',
|
||||
_lastName: 'User',
|
||||
globalRole: 'global:admin',
|
||||
projectId: 'proj-brie',
|
||||
projectIds: ['proj-brie', 'proj-stilton'],
|
||||
folderId: 'folder-cheese',
|
||||
workflowIds: ['wf-cheddar', 'wf-brie'],
|
||||
options: {
|
||||
|
|
|
|||
|
|
@ -2223,7 +2223,7 @@ describe('TelemetryEventRelay', () => {
|
|||
it('should track on `n8n-package-imported` event with params and counts', () => {
|
||||
const event: RelayEventMap['n8n-package-imported'] = {
|
||||
user: { id: 'user123' },
|
||||
projectId: 'project123',
|
||||
projectIds: ['project123'],
|
||||
folderId: 'folder123',
|
||||
workflowIds: ['wf1', 'wf2', 'wf3'],
|
||||
options: {
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export type RelayEventMap = {
|
|||
|
||||
'n8n-package-imported': {
|
||||
user: UserLike;
|
||||
projectId: string;
|
||||
projectIds: string[];
|
||||
folderId: string | null;
|
||||
workflowIds: string[];
|
||||
options: ImportPackageEventOptions;
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ flowchart LR
|
|||
workflows + their folder shells + credential deps into a target project) → `WorkflowPackageImporter`.
|
||||
- `WorkflowPackageImporter` resolves the target scope from the request, then delegates the plan/gate/apply
|
||||
work to `ImportOrchestrator` (brings folders + workflows + credential deps into one project scope).
|
||||
`ProjectPackageImporter` creates the project shells; reusing `ImportOrchestrator` for a project's own
|
||||
contents is a follow-up. Don't split folder vs workflow: they share target resolution, credential
|
||||
resolution, and publishing.
|
||||
`ProjectPackageImporter` creates the project shells, then reuses `ImportOrchestrator` per project to
|
||||
bring each one's own folders + workflows + credential deps into scope. Don't split folder vs workflow:
|
||||
they share target resolution, credential resolution, and publishing.
|
||||
|
||||
### Adding an IMPORT property
|
||||
|
||||
|
|
|
|||
|
|
@ -505,11 +505,48 @@ describe('folder shell import', () => {
|
|||
},
|
||||
});
|
||||
|
||||
// The folder-nested workflow is now imported (LIGO-723), so its missing must-preexist credential
|
||||
// resolves through the same gate as a top-level workflow and blocks the import before any writes.
|
||||
await expect(
|
||||
importFolders({ user: owner, projectId: project.id, packageBuffer }),
|
||||
).rejects.toBeInstanceOf(UnprocessableRequestError);
|
||||
expect(await findFolder('F1')).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks re-importing a matched workflow into a different folder and never re-parents it', async () => {
|
||||
const first = await importFolders({
|
||||
user: owner,
|
||||
projectId: project.id,
|
||||
packageBuffer: await buildEntityPackageBuffer({
|
||||
folders: [{ target: 'folders/fa', folder: serializedFolder({ id: 'FA', name: 'fa' }) }],
|
||||
workflows: [
|
||||
{
|
||||
target: 'folders/fa/workflows/wf',
|
||||
workflow: serializedWorkflow({ id: 'WF', name: 'wf' }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const localId = first.workflows[0].localId;
|
||||
expect((await findWorkflow(localId))?.parentFolder?.id).toBe('FA');
|
||||
|
||||
// Re-import the same source workflow nested under a different folder: the matched workflow is not
|
||||
// re-parented; the mismatch blocks the whole import before anything is written.
|
||||
await expect(
|
||||
importFolders({
|
||||
user: owner,
|
||||
projectId: project.id,
|
||||
packageBuffer: await buildEntityPackageBuffer({
|
||||
folders: [{ target: 'folders/fb', folder: serializedFolder({ id: 'FB', name: 'fb' }) }],
|
||||
workflows: [
|
||||
{
|
||||
target: 'folders/fb/workflows/wf',
|
||||
workflow: serializedWorkflow({ id: 'WF', name: 'wf' }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictError);
|
||||
|
||||
expect((await findWorkflow(localId))?.parentFolder?.id).toBe('FA');
|
||||
expect(await findFolder('FB')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
import { LicenseState } from '@n8n/backend-common';
|
||||
import { testDb, testModules } from '@n8n/backend-test-utils';
|
||||
import type { User } from '@n8n/db';
|
||||
import { FolderRepository, ProjectRelationRepository, ProjectRepository } from '@n8n/db';
|
||||
import {
|
||||
FolderRepository,
|
||||
ProjectRelationRepository,
|
||||
ProjectRepository,
|
||||
SharedWorkflowRepository,
|
||||
WorkflowRepository,
|
||||
} from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import type { RelayEventMap } from '@/events/maps/relay.event-map';
|
||||
import { createOwner } from '@test-integration/db/users';
|
||||
import { LicenseMocker } from '@test-integration/license';
|
||||
|
||||
|
|
@ -12,12 +21,19 @@ import { N8nPackagesService } from '../n8n-packages.service';
|
|||
import type { ImportPackageRequest } from '../n8n-packages.types';
|
||||
import {
|
||||
buildEntityPackageBuffer,
|
||||
credentialRequirementsFromWorkflows,
|
||||
serializedFolder,
|
||||
serializedProject,
|
||||
serializedWorkflow,
|
||||
serializedWorkflowWithCredential,
|
||||
} from './fixtures/package-fixtures';
|
||||
|
||||
async function importProjects(user: User, packageBuffer: Buffer, apiKeyScopes?: string[]) {
|
||||
async function importProjects(
|
||||
user: User,
|
||||
packageBuffer: Buffer,
|
||||
apiKeyScopes?: string[],
|
||||
overrides?: Partial<ImportPackageRequest>,
|
||||
) {
|
||||
const request: ImportPackageRequest = {
|
||||
user,
|
||||
packageBuffer,
|
||||
|
|
@ -31,6 +47,7 @@ async function importProjects(user: User, packageBuffer: Buffer, apiKeyScopes?:
|
|||
dataTableMatchingMode: 'by-id',
|
||||
dataTableMissingMode: 'create',
|
||||
dataTableSchemaConflictPolicy: 'keep-existing',
|
||||
...overrides,
|
||||
};
|
||||
return await Container.get(N8nPackagesService).importPackage(request);
|
||||
}
|
||||
|
|
@ -41,6 +58,20 @@ async function findProject(id: string) {
|
|||
return await Container.get(ProjectRepository).findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async function findFolder(id: string) {
|
||||
return await Container.get(FolderRepository).findOne({
|
||||
where: { id },
|
||||
relations: { homeProject: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function findWorkflow(id: string) {
|
||||
return await Container.get(WorkflowRepository).findOne({
|
||||
where: { id },
|
||||
relations: { parentFolder: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function isAdminOf(projectId: string, userId: string): Promise<boolean> {
|
||||
const count = await Container.get(ProjectRelationRepository).count({
|
||||
where: { projectId, userId, role: { slug: 'project:admin' } },
|
||||
|
|
@ -53,7 +84,7 @@ beforeAll(async () => {
|
|||
await testDb.init();
|
||||
licenseMocker.mockLicenseState(Container.get(LicenseState));
|
||||
licenseMocker.setDefaults({
|
||||
features: ['feat:projectRole:admin'],
|
||||
features: ['feat:projectRole:admin', 'feat:folders'],
|
||||
quotas: { 'quota:maxTeamProjects': 100 },
|
||||
});
|
||||
});
|
||||
|
|
@ -156,6 +187,23 @@ describe('project shell import', () => {
|
|||
expect((await findProject('P1'))?.name).toBe('brie renamed');
|
||||
});
|
||||
|
||||
it('rejects a project package whose manifest project id disagrees with its project.json', async () => {
|
||||
const packageBuffer = await buildEntityPackageBuffer({
|
||||
projects: [
|
||||
{ target: 'projects/p', project: serializedProject({ id: 'real-id', name: 'p' }) },
|
||||
],
|
||||
// Manifest points the same target at a different id than project.json declares. The project is
|
||||
// created under project.json's id, but its contents scope by manifest id — so they must agree.
|
||||
manifestExtras: { projects: [{ id: 'manifest-id', name: 'p', target: 'projects/p' }] },
|
||||
});
|
||||
|
||||
await expect(importProjects(owner, packageBuffer)).rejects.toThrow(
|
||||
/declares id "real-id" but the manifest lists it as "manifest-id"/,
|
||||
);
|
||||
// Rejected while parsing, before any project shell is created.
|
||||
expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a project package when the API key lacks the project:create scope', async () => {
|
||||
const packageBuffer = await buildEntityPackageBuffer({
|
||||
projects: [
|
||||
|
|
@ -181,30 +229,275 @@ describe('project shell import', () => {
|
|||
await expect(importProjects(owner, packageBuffer)).rejects.toBeInstanceOf(ForbiddenError);
|
||||
});
|
||||
|
||||
it('ignores folders and workflows nested inside the project (deferred to a follow-up)', async () => {
|
||||
it('recreates the project folder tree and places nested workflows into the project scope', async () => {
|
||||
const packageBuffer = await buildEntityPackageBuffer({
|
||||
projects: [
|
||||
{ target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) },
|
||||
{
|
||||
target: 'projects/team-ligo',
|
||||
project: serializedProject({ id: 'P1', name: 'team-ligo' }),
|
||||
},
|
||||
],
|
||||
folders: [
|
||||
// An empty folder shell alongside a populated, nested hierarchy.
|
||||
{
|
||||
target: 'projects/brie/folders/in_progress',
|
||||
folder: serializedFolder({ id: 'NestedFolder', name: 'in_progress' }),
|
||||
target: 'projects/team-ligo/folders/to_production',
|
||||
folder: serializedFolder({ id: 'TP', name: 'to_production' }),
|
||||
},
|
||||
{
|
||||
target: 'projects/team-ligo/folders/in_progress',
|
||||
folder: serializedFolder({ id: 'IP', name: 'in_progress' }),
|
||||
},
|
||||
{
|
||||
target: 'projects/team-ligo/folders/in_progress/nested',
|
||||
folder: serializedFolder({ id: 'NE', name: 'nested', parentFolderId: 'IP' }),
|
||||
},
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
target: 'projects/brie/folders/in_progress/workflows/triage',
|
||||
workflow: serializedWorkflow({ id: 'NestedWf', name: 'triage' }),
|
||||
target: 'projects/team-ligo/folders/in_progress/workflows/triage',
|
||||
workflow: serializedWorkflow({ id: 'WF1', name: 'triage' }),
|
||||
},
|
||||
{
|
||||
target: 'projects/team-ligo/folders/in_progress/nested/workflows/playground',
|
||||
workflow: serializedWorkflow({ id: 'WF2', name: 'playground' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await importProjects(owner, packageBuffer);
|
||||
|
||||
expect(result.projects.map((p) => p.localId)).toEqual(['P1']);
|
||||
expect(result.folders).toEqual([]);
|
||||
expect(result.workflows).toEqual([]);
|
||||
expect(await Container.get(FolderRepository).findOneBy({ id: 'NestedFolder' })).toBeNull();
|
||||
expect(result.projects).toEqual([
|
||||
{ sourceProjectId: 'P1', localId: 'P1', name: 'team-ligo', status: 'created' },
|
||||
]);
|
||||
// Every folder (incl. the empty shell) lands in the project; the nested one keeps its parent.
|
||||
for (const id of ['TP', 'IP', 'NE']) {
|
||||
expect((await findFolder(id))?.homeProject.id).toBe('P1');
|
||||
}
|
||||
expect((await findFolder('NE'))?.parentFolderId).toBe('IP');
|
||||
// Each workflow is placed under the folder it belongs to, scoped to the project.
|
||||
const triage = result.workflows.find((w) => w.sourceWorkflowId === 'WF1')!;
|
||||
const playground = result.workflows.find((w) => w.sourceWorkflowId === 'WF2')!;
|
||||
expect(triage.projectId).toBe('P1');
|
||||
expect((await findWorkflow(triage.localId))?.parentFolder?.id).toBe('IP');
|
||||
expect((await findWorkflow(playground.localId))?.parentFolder?.id).toBe('NE');
|
||||
});
|
||||
|
||||
it('populates each project in a multi-project package into its own scope', async () => {
|
||||
const packageBuffer = await buildEntityPackageBuffer({
|
||||
projects: [
|
||||
{ target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) },
|
||||
{ target: 'projects/stilton', project: serializedProject({ id: 'P2', name: 'stilton' }) },
|
||||
],
|
||||
folders: [
|
||||
{ target: 'projects/brie/folders/a', folder: serializedFolder({ id: 'FA', name: 'a' }) },
|
||||
{ target: 'projects/stilton/folders/b', folder: serializedFolder({ id: 'FB', name: 'b' }) },
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
target: 'projects/brie/folders/a/workflows/wfa',
|
||||
workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }),
|
||||
},
|
||||
{
|
||||
target: 'projects/stilton/folders/b/workflows/wfb',
|
||||
workflow: serializedWorkflow({ id: 'WFB', name: 'wfb' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await importProjects(owner, packageBuffer);
|
||||
|
||||
expect(result.projects.map((p) => p.localId).sort()).toEqual(['P1', 'P2']);
|
||||
expect((await findFolder('FA'))?.homeProject.id).toBe('P1');
|
||||
expect((await findFolder('FB'))?.homeProject.id).toBe('P2');
|
||||
const wfa = result.workflows.find((w) => w.sourceWorkflowId === 'WFA')!;
|
||||
const wfb = result.workflows.find((w) => w.sourceWorkflowId === 'WFB')!;
|
||||
expect(wfa.projectId).toBe('P1');
|
||||
expect(wfb.projectId).toBe('P2');
|
||||
expect((await findWorkflow(wfa.localId))?.parentFolder?.id).toBe('FA');
|
||||
expect((await findWorkflow(wfb.localId))?.parentFolder?.id).toBe('FB');
|
||||
});
|
||||
|
||||
it('reuses the project and folder and updates the workflow on re-import', async () => {
|
||||
const pkg = async () =>
|
||||
await buildEntityPackageBuffer({
|
||||
projects: [
|
||||
{ target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) },
|
||||
],
|
||||
folders: [
|
||||
{ target: 'projects/brie/folders/a', folder: serializedFolder({ id: 'FA', name: 'a' }) },
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
target: 'projects/brie/folders/a/workflows/wf',
|
||||
workflow: serializedWorkflow({ id: 'WF', name: 'wf' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const first = await importProjects(owner, await pkg());
|
||||
const localId = first.workflows[0].localId;
|
||||
|
||||
const second = await importProjects(owner, await pkg());
|
||||
|
||||
expect(second.workflows[0]).toMatchObject({ status: 'updated', localId, parentFolderId: 'FA' });
|
||||
expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(1);
|
||||
expect(await Container.get(FolderRepository).countBy({ id: 'FA' })).toBe(1);
|
||||
expect(await Container.get(WorkflowRepository).countBy({ id: localId })).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves a project workflow credential in the project scope, blocking when it is missing', async () => {
|
||||
const workflow = serializedWorkflowWithCredential({
|
||||
id: 'WF',
|
||||
name: 'triage',
|
||||
credentialId: 'missing-cred',
|
||||
credentialName: 'Linear',
|
||||
});
|
||||
const packageBuffer = await buildEntityPackageBuffer({
|
||||
projects: [
|
||||
{ target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) },
|
||||
],
|
||||
folders: [
|
||||
{ target: 'projects/brie/folders/a', folder: serializedFolder({ id: 'FA', name: 'a' }) },
|
||||
],
|
||||
workflows: [{ target: 'projects/brie/folders/a/workflows/triage', workflow }],
|
||||
manifestExtras: {
|
||||
requirements: { credentials: credentialRequirementsFromWorkflows([workflow]) },
|
||||
},
|
||||
});
|
||||
|
||||
// The project workflow's credential requirement resolves in the project scope; under must-preexist
|
||||
// a missing credential blocks the import before anything is written — no folder, no project shell.
|
||||
await expect(importProjects(owner, packageBuffer)).rejects.toBeInstanceOf(
|
||||
UnprocessableRequestError,
|
||||
);
|
||||
expect(await findFolder('FA')).toBeNull();
|
||||
expect(await findProject('P1')).toBeNull();
|
||||
});
|
||||
|
||||
it('imports a project-root workflow (no enclosing folder) at the project root', async () => {
|
||||
const packageBuffer = await buildEntityPackageBuffer({
|
||||
projects: [
|
||||
{ target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) },
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
target: 'projects/brie/workflows/root-wf',
|
||||
workflow: serializedWorkflow({ id: 'RWF', name: 'root-wf' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await importProjects(owner, packageBuffer);
|
||||
|
||||
const summary = result.workflows.find((w) => w.sourceWorkflowId === 'RWF')!;
|
||||
expect(summary).toMatchObject({ projectId: 'P1', parentFolderId: null });
|
||||
// Persisted at the project root (no parent folder), owned by the imported project.
|
||||
expect((await findWorkflow(summary.localId))?.parentFolder).toBeNull();
|
||||
const shared = await Container.get(SharedWorkflowRepository).findOneBy({
|
||||
workflowId: summary.localId,
|
||||
});
|
||||
expect(shared?.projectId).toBe('P1');
|
||||
});
|
||||
|
||||
it('gates the whole package: a later project blocking leaves earlier projects unwritten', async () => {
|
||||
const blocked = serializedWorkflowWithCredential({
|
||||
id: 'WFB',
|
||||
name: 'wfb',
|
||||
credentialId: 'missing-cred',
|
||||
credentialName: 'Linear',
|
||||
});
|
||||
const packageBuffer = await buildEntityPackageBuffer({
|
||||
projects: [
|
||||
{ target: 'projects/alpha', project: serializedProject({ id: 'P1', name: 'alpha' }) },
|
||||
{ target: 'projects/beta', project: serializedProject({ id: 'P2', name: 'beta' }) },
|
||||
],
|
||||
folders: [
|
||||
{ target: 'projects/alpha/folders/a', folder: serializedFolder({ id: 'FA', name: 'a' }) },
|
||||
{ target: 'projects/beta/folders/b', folder: serializedFolder({ id: 'FB', name: 'b' }) },
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
target: 'projects/alpha/folders/a/workflows/wfa',
|
||||
workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }),
|
||||
},
|
||||
{ target: 'projects/beta/folders/b/workflows/wfb', workflow: blocked },
|
||||
],
|
||||
manifestExtras: {
|
||||
requirements: { credentials: credentialRequirementsFromWorkflows([blocked]) },
|
||||
},
|
||||
});
|
||||
|
||||
// The second project's workflow needs a missing credential: every project is planned and
|
||||
// validated before anything is written, so a block leaves nothing behind — not the first
|
||||
// project's folder/workflow, nor either project shell.
|
||||
await expect(importProjects(owner, packageBuffer)).rejects.toBeInstanceOf(
|
||||
UnprocessableRequestError,
|
||||
);
|
||||
expect(await findFolder('FA')).toBeNull();
|
||||
expect(await findFolder('FB')).toBeNull();
|
||||
expect(await findProject('P1')).toBeNull();
|
||||
expect(await findProject('P2')).toBeNull();
|
||||
});
|
||||
|
||||
it('creates a new project under publish-all, planned before the project exists', async () => {
|
||||
const packageBuffer = await buildEntityPackageBuffer({
|
||||
projects: [
|
||||
{ target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) },
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
target: 'projects/brie/workflows/wf',
|
||||
workflow: serializedWorkflow({ id: 'WF', name: 'wf' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// publish-all checks publish permission during planning, which now runs before the project
|
||||
// is created. A project being created has no row to look up, so the import must not fail — its
|
||||
// creator is admin and can always publish.
|
||||
const result = await importProjects(owner, packageBuffer, undefined, {
|
||||
workflowPublishingPolicy: 'publish-all',
|
||||
});
|
||||
|
||||
expect(result.projects).toEqual([
|
||||
{ sourceProjectId: 'P1', localId: 'P1', name: 'brie', status: 'created' },
|
||||
]);
|
||||
expect(result.workflows.find((w) => w.sourceWorkflowId === 'WF')?.projectId).toBe('P1');
|
||||
expect(await findProject('P1')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('emits a single n8n-package-imported event aggregating every project in the package', async () => {
|
||||
const packageBuffer = await buildEntityPackageBuffer({
|
||||
projects: [
|
||||
{ target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) },
|
||||
{ target: 'projects/stilton', project: serializedProject({ id: 'P2', name: 'stilton' }) },
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
target: 'projects/brie/workflows/wfa',
|
||||
workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }),
|
||||
},
|
||||
{
|
||||
target: 'projects/stilton/workflows/wfb',
|
||||
workflow: serializedWorkflow({ id: 'WFB', name: 'wfb' }),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
|
||||
try {
|
||||
await importProjects(owner, packageBuffer);
|
||||
|
||||
const importedEvents = emitSpy.mock.calls.filter(([name]) => name === 'n8n-package-imported');
|
||||
expect(importedEvents).toHaveLength(1);
|
||||
|
||||
const payload = importedEvents[0][1] as RelayEventMap['n8n-package-imported'];
|
||||
expect(payload.projectIds.sort()).toEqual(['P1', 'P2']);
|
||||
expect(payload.workflowIds).toHaveLength(2);
|
||||
expect(payload.folderId).toBeNull();
|
||||
expect(payload.counts.workflows.created).toBe(2);
|
||||
} finally {
|
||||
emitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
import type { PreparedWorkflow } from '../../entities/workflow/workflow-import.types';
|
||||
import type { ImportBindingMap } from '../../n8n-packages.types';
|
||||
import type { PackageCredentialRequirement } from '../../spec/requirements.schema';
|
||||
import { identifyRequirements, scopeCredentialBindingsToRequirements } from '../import-result';
|
||||
|
||||
const requirement = (id: string, usedByWorkflows: string[]): PackageCredentialRequirement => ({
|
||||
id,
|
||||
name: id,
|
||||
type: 'githubApi',
|
||||
usedByWorkflows,
|
||||
});
|
||||
|
||||
const prepared = (sourceWorkflowId: string): PreparedWorkflow =>
|
||||
({ sourceWorkflowId }) as PreparedWorkflow;
|
||||
|
||||
describe('identifyRequirements', () => {
|
||||
it('returns undefined when there are no requirements', () => {
|
||||
expect(identifyRequirements(undefined, [prepared('W1')])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps only in-scope workflows and drops requirements that no in-scope workflow uses', () => {
|
||||
const requirements = [requirement('credA', ['W1', 'W2']), requirement('credB', ['W3'])];
|
||||
|
||||
const scoped = identifyRequirements(requirements, [prepared('W1')]);
|
||||
|
||||
// credA stays (W1 is in scope) but with W2 trimmed off; credB drops entirely (W3 is out of scope).
|
||||
expect(scoped).toEqual([requirement('credA', ['W1'])]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopeCredentialBindingsToRequirements', () => {
|
||||
const bindings: ImportBindingMap = new Map([
|
||||
['credA', 'target-a'],
|
||||
['credB', 'target-b'],
|
||||
]);
|
||||
|
||||
it('returns undefined when no bindings were supplied', () => {
|
||||
expect(
|
||||
scopeCredentialBindingsToRequirements(undefined, [requirement('credA', ['W1'])]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps only bindings whose source id this scope requires', () => {
|
||||
// Simulates a multi-project import where credB belongs to another project's workflows.
|
||||
const scoped = scopeCredentialBindingsToRequirements(bindings, [requirement('credA', ['W1'])]);
|
||||
|
||||
expect(scoped).toEqual(new Map([['credA', 'target-a']]));
|
||||
});
|
||||
|
||||
it('drops every binding when the scope has no requirements', () => {
|
||||
expect(scopeCredentialBindingsToRequirements(bindings, undefined)).toEqual(new Map());
|
||||
expect(scopeCredentialBindingsToRequirements(bindings, [])).toEqual(new Map());
|
||||
});
|
||||
|
||||
it('keeps every binding when all are required by the scope', () => {
|
||||
const scoped = scopeCredentialBindingsToRequirements(bindings, [
|
||||
requirement('credA', ['W1']),
|
||||
requirement('credB', ['W2']),
|
||||
]);
|
||||
|
||||
expect(scoped).toEqual(bindings);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
import type { WorkflowEntity } from '@n8n/db';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { EventService } from '@/events/event.service';
|
||||
import type { RelayEventMap } from '@/events/maps/relay.event-map';
|
||||
|
||||
import type { CredentialApplyResult } from '../../entities/credential/credential.types';
|
||||
import type { DataTableImportRequest } from '../../entities/data-table/data-table.types';
|
||||
import type { WorkflowImportOutcome } from '../../entities/workflow/workflow-import.types';
|
||||
import type { ImportContext, ImportPackageRequest } from '../../n8n-packages.types';
|
||||
import type { PackageManifest } from '../../spec/manifest.schema';
|
||||
import type { PackageCredentialRequirement } from '../../spec/requirements.schema';
|
||||
import type { ImportOrchestrationResult } from '../import-orchestrator';
|
||||
import { emitPackageImportedEvent, type PackageImportScope } from '../import-telemetry';
|
||||
|
||||
const outcome = (
|
||||
id: string,
|
||||
sourceWorkflowId: string,
|
||||
status: WorkflowImportOutcome['status'],
|
||||
): WorkflowImportOutcome => ({
|
||||
status,
|
||||
sourceWorkflowId,
|
||||
workflow: mock<WorkflowEntity>({ id }),
|
||||
publishing: { state: 'unchanged' },
|
||||
});
|
||||
|
||||
const requirement = (id: string): PackageCredentialRequirement => ({
|
||||
id,
|
||||
name: id,
|
||||
type: 'githubApi',
|
||||
usedByWorkflows: ['ignored'],
|
||||
});
|
||||
|
||||
const scope = (input: {
|
||||
projectId: string;
|
||||
folderId?: string | null;
|
||||
outcomes: WorkflowImportOutcome[];
|
||||
credentialResult: CredentialApplyResult;
|
||||
requirements?: PackageCredentialRequirement[];
|
||||
dataTable?: { matched: number; created: number; requirements: number };
|
||||
}): PackageImportScope => {
|
||||
const context: ImportContext = {
|
||||
user: mock(),
|
||||
projectId: input.projectId,
|
||||
folderId: input.folderId ?? null,
|
||||
};
|
||||
const dt = input.dataTable ?? { matched: 0, created: 0, requirements: 0 };
|
||||
const imported: ImportOrchestrationResult = {
|
||||
workflowOutcomes: input.outcomes,
|
||||
folderSummaries: [],
|
||||
bindings: { workflows: new Map(), credentials: new Map() },
|
||||
credentialResult: input.credentialResult,
|
||||
dataTablePlan: { creations: new Array(dt.created), failures: [], matchedCount: dt.matched },
|
||||
};
|
||||
return {
|
||||
context,
|
||||
imported,
|
||||
credentialRequest: {
|
||||
requirements: input.requirements,
|
||||
matchingMode: 'id-only',
|
||||
missingMode: 'create-stub',
|
||||
credentialBindings: undefined,
|
||||
},
|
||||
dataTableRequest: mock<DataTableImportRequest>({
|
||||
requirements: dt.requirements === 0 ? undefined : new Array(dt.requirements),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const request = mock<ImportPackageRequest>({
|
||||
user: mock(),
|
||||
workflowConflictPolicy: 'new-version',
|
||||
workflowIdPolicy: 'new',
|
||||
credentialMatchingMode: 'id-only',
|
||||
credentialMissingMode: 'create-stub',
|
||||
workflowPublishingPolicy: 'preserve-published-state',
|
||||
});
|
||||
|
||||
const manifest = mock<PackageManifest>({ sourceId: 'src-1', packageFormatVersion: '1' });
|
||||
|
||||
function lastImportedPayload(
|
||||
eventService: ReturnType<typeof mock<EventService>>,
|
||||
): RelayEventMap['n8n-package-imported'] {
|
||||
expect(eventService.emit).toHaveBeenCalledTimes(1);
|
||||
const [eventName, payload] = eventService.emit.mock.calls[0];
|
||||
expect(eventName).toBe('n8n-package-imported');
|
||||
return payload as RelayEventMap['n8n-package-imported'];
|
||||
}
|
||||
|
||||
describe('emitPackageImportedEvent', () => {
|
||||
it('aggregates counts, project ids and credential ids across every scope', () => {
|
||||
const eventService = mock<EventService>();
|
||||
|
||||
emitPackageImportedEvent(eventService, {
|
||||
request,
|
||||
manifest,
|
||||
scopes: [
|
||||
scope({
|
||||
projectId: 'P1',
|
||||
folderId: 'F1',
|
||||
outcomes: [outcome('wf1', 'WF1', 'created'), outcome('wf2', 'WF2', 'skipped')],
|
||||
credentialResult: {
|
||||
bindings: new Map([['credA', 'target-a']]),
|
||||
matched: ['credA'],
|
||||
stubbed: [],
|
||||
},
|
||||
requirements: [requirement('credA')],
|
||||
dataTable: { matched: 1, created: 0, requirements: 1 },
|
||||
}),
|
||||
scope({
|
||||
projectId: 'P2',
|
||||
outcomes: [outcome('wf3', 'WF3', 'updated')],
|
||||
credentialResult: {
|
||||
bindings: new Map([['credB', 'stub-b']]),
|
||||
matched: [],
|
||||
stubbed: ['credB'],
|
||||
},
|
||||
requirements: [requirement('credB')],
|
||||
dataTable: { matched: 0, created: 2, requirements: 2 },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const payload = lastImportedPayload(eventService);
|
||||
expect(payload.projectIds).toEqual(['P1', 'P2']);
|
||||
// Skipped workflows are excluded; only wf1 and wf3 were actually written.
|
||||
expect(payload.workflowIds).toEqual(['wf1', 'wf3']);
|
||||
// A multi-scope import has no single folder to attribute the event to.
|
||||
expect(payload.folderId).toBeNull();
|
||||
// Credential ids are resolved through each scope's binding map (source id -> target id).
|
||||
expect(payload.credentialIds).toEqual({
|
||||
matched: ['target-a'],
|
||||
created: ['stub-b'],
|
||||
updated: [],
|
||||
});
|
||||
expect(payload.counts).toEqual({
|
||||
workflows: { created: 1, updated: 1, skipped: 1 },
|
||||
credentials: { matched: 1, created: 1, requirements: 2 },
|
||||
dataTables: { matched: 1, created: 2, requirements: 3 },
|
||||
});
|
||||
expect(payload.packageSourceId).toBe('src-1');
|
||||
});
|
||||
|
||||
it('preserves the folder id for a single-scope import', () => {
|
||||
const eventService = mock<EventService>();
|
||||
|
||||
emitPackageImportedEvent(eventService, {
|
||||
request,
|
||||
manifest,
|
||||
scopes: [
|
||||
scope({
|
||||
projectId: 'P1',
|
||||
folderId: 'F1',
|
||||
outcomes: [outcome('wf1', 'WF1', 'created')],
|
||||
credentialResult: { bindings: new Map(), matched: [], stubbed: [] },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(lastImportedPayload(eventService).folderId).toBe('F1');
|
||||
});
|
||||
});
|
||||
|
|
@ -78,5 +78,18 @@ describe('package-layout', () => {
|
|||
it('returns null for a project-root workflow even when its project has no folder entry', () => {
|
||||
expect(deriveParentFolderId('projects/unknown/workflows/wf', map)).toBeNull();
|
||||
});
|
||||
|
||||
it('splits on the LAST /workflows/ so a folder literally named "workflows" resolves correctly', () => {
|
||||
// A folder named "workflows" keeps its bare slug when its parent has no directly-contained
|
||||
// workflows; a workflow inside it must resolve to that folder, not its grandparent.
|
||||
const withWorkflowsFolder = new Map([
|
||||
['folders/a', 'A'],
|
||||
['folders/a/workflows', 'W'],
|
||||
]);
|
||||
expect(deriveParentFolderId('folders/a/workflows/workflows/wf', withWorkflowsFolder)).toBe(
|
||||
'W',
|
||||
);
|
||||
expect(deriveParentFolderId('folders/a/workflows/wf', withWorkflowsFolder)).toBe('A');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Service } from '@n8n/di';
|
||||
|
||||
import { toImportBlockedError } from './import-blocked.error';
|
||||
import { CredentialImporter } from '../entities/credential/credential-importer';
|
||||
import { workflowsBlockedFromPublish } from '../entities/credential/credential-missing-mode';
|
||||
import type {
|
||||
|
|
@ -13,8 +14,12 @@ import type {
|
|||
DataTableImportPlan,
|
||||
DataTableImportRequest,
|
||||
} from '../entities/data-table/data-table.types';
|
||||
import type {
|
||||
FolderImportContext,
|
||||
FolderImportPlan,
|
||||
PreparedFolder,
|
||||
} from '../entities/folder/folder-import.types';
|
||||
import { FolderImporter } from '../entities/folder/folder-importer';
|
||||
import type { FolderImportPlan, PreparedFolder } from '../entities/folder/folder-import.types';
|
||||
import type {
|
||||
PreparedWorkflow,
|
||||
WorkflowImportOutcome,
|
||||
|
|
@ -31,7 +36,6 @@ import type {
|
|||
ImportWorkflowProperties,
|
||||
PackageImportBindings,
|
||||
} from '../n8n-packages.types';
|
||||
import { toImportBlockedError } from './import-blocked.error';
|
||||
|
||||
export interface ImportOrchestrationInput {
|
||||
context: ImportContext;
|
||||
|
|
@ -40,6 +44,8 @@ export interface ImportOrchestrationInput {
|
|||
credentialRequest: CredentialBindingRequest;
|
||||
dataTableRequest: DataTableImportRequest;
|
||||
options: ImportWorkflowProperties & ImportFolderProperties;
|
||||
/** The target project does not exist yet and will be created by this import (project packages). */
|
||||
projectPendingCreation?: boolean;
|
||||
}
|
||||
|
||||
export interface ImportOrchestrationResult {
|
||||
|
|
@ -50,6 +56,16 @@ export interface ImportOrchestrationResult {
|
|||
dataTablePlan: DataTableImportPlan;
|
||||
}
|
||||
|
||||
export interface ImportPlan {
|
||||
input: ImportOrchestrationInput;
|
||||
folderContext: FolderImportContext;
|
||||
credentialPlan: CredentialResolution;
|
||||
workflowPlan: WorkflowImportPlan;
|
||||
folderPlan: FolderImportPlan;
|
||||
dataTablePlan: DataTableImportPlan;
|
||||
blockingIssues: BlockingIssue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates the credential, folder, and workflow importers to bring a package's
|
||||
* contents into one resolved project scope
|
||||
|
|
@ -65,13 +81,21 @@ export class ImportOrchestrator {
|
|||
) {}
|
||||
|
||||
async import(input: ImportOrchestrationInput): Promise<ImportOrchestrationResult> {
|
||||
const plan = await this.plan(input);
|
||||
if (plan.blockingIssues.length > 0) {
|
||||
throw toImportBlockedError(plan.blockingIssues);
|
||||
}
|
||||
return await this.apply(plan);
|
||||
}
|
||||
|
||||
async plan(input: ImportOrchestrationInput): Promise<ImportPlan> {
|
||||
const { context, folders, workflows, credentialRequest, dataTableRequest, options } = input;
|
||||
|
||||
// PublishAll requires publish scope up front; other policies are checked per workflow.
|
||||
await this.workflowPublisher.assertCanPublish(
|
||||
context.user,
|
||||
context.projectId,
|
||||
options.workflowPublishingPolicy,
|
||||
input.projectPendingCreation,
|
||||
);
|
||||
|
||||
const credentialPlan = await this.credentialImporter.plan(context, credentialRequest);
|
||||
|
|
@ -88,9 +112,20 @@ export class ImportOrchestrator {
|
|||
dataTablePlan,
|
||||
});
|
||||
|
||||
if (blockingIssues.length > 0) {
|
||||
throw toImportBlockedError(blockingIssues);
|
||||
}
|
||||
return {
|
||||
input,
|
||||
folderContext,
|
||||
credentialPlan,
|
||||
workflowPlan,
|
||||
folderPlan,
|
||||
dataTablePlan,
|
||||
blockingIssues,
|
||||
};
|
||||
}
|
||||
|
||||
async apply(plan: ImportPlan): Promise<ImportOrchestrationResult> {
|
||||
const { input, folderContext, credentialPlan, workflowPlan, folderPlan, dataTablePlan } = plan;
|
||||
const { context, credentialRequest, options } = input;
|
||||
|
||||
const folderSummaries = await this.folderImporter.apply(folderContext, folderPlan);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
|
||||
import type { WorkflowImportOutcome } from '../entities/workflow/workflow-import.types';
|
||||
import type {
|
||||
PreparedWorkflow,
|
||||
WorkflowImportOutcome,
|
||||
} from '../entities/workflow/workflow-import.types';
|
||||
import { serializeBindings } from '../n8n-packages.types';
|
||||
import type {
|
||||
ImportBindingMap,
|
||||
ImportCredentialSummary,
|
||||
ImportedFolderSummary,
|
||||
ImportedProjectSummary,
|
||||
ImportedWorkflowSummary,
|
||||
ImportPackageSummary,
|
||||
ImportResult,
|
||||
PackageImportBindings,
|
||||
} from '../n8n-packages.types';
|
||||
import type { PackageManifest } from '../spec/manifest.schema';
|
||||
import type { PackageCredentialRequirement } from '../spec/requirements.schema';
|
||||
|
||||
export function toPackageSummary(manifest: PackageManifest): ImportPackageSummary {
|
||||
return {
|
||||
|
|
@ -20,11 +26,25 @@ export function toPackageSummary(manifest: PackageManifest): ImportPackageSummar
|
|||
};
|
||||
}
|
||||
|
||||
/** Assembles the wire {@link ImportResult} from each entity importer's outcome. */
|
||||
export function toImportedWorkflowSummaries(
|
||||
outcomes: WorkflowImportOutcome[],
|
||||
projectId: string,
|
||||
): ImportedWorkflowSummary[] {
|
||||
return outcomes.map(({ workflow, sourceWorkflowId, status, publishing }) => ({
|
||||
sourceWorkflowId,
|
||||
localId: workflow.id,
|
||||
name: workflow.name,
|
||||
projectId,
|
||||
parentFolderId: workflow.parentFolder?.id ?? null,
|
||||
activeVersionId: workflow.activeVersionId ?? null,
|
||||
publishing,
|
||||
status,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildImportResult(input: {
|
||||
package: ImportPackageSummary;
|
||||
projectId: string | null;
|
||||
workflows: WorkflowImportOutcome[];
|
||||
workflows: ImportedWorkflowSummary[];
|
||||
folders: ImportedFolderSummary[];
|
||||
projects: ImportedProjectSummary[];
|
||||
bindings: PackageImportBindings;
|
||||
|
|
@ -32,16 +52,7 @@ export function buildImportResult(input: {
|
|||
}): ImportResult {
|
||||
return {
|
||||
package: input.package,
|
||||
workflows: input.workflows.map(({ workflow, sourceWorkflowId, status, publishing }) => ({
|
||||
sourceWorkflowId,
|
||||
localId: workflow.id,
|
||||
name: workflow.name,
|
||||
projectId: input.projectId ?? '',
|
||||
parentFolderId: workflow.parentFolder?.id ?? null,
|
||||
activeVersionId: workflow.activeVersionId ?? null,
|
||||
publishing,
|
||||
status,
|
||||
})),
|
||||
workflows: input.workflows,
|
||||
folders: input.folders,
|
||||
projects: input.projects,
|
||||
bindings: serializeBindings(input.bindings),
|
||||
|
|
@ -64,3 +75,34 @@ export function assertPackageImportApiKeyScopes(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps only the requirements used by the imported workflows, trimming `usedByWorkflows` to match. */
|
||||
export function identifyRequirements<T extends { usedByWorkflows: string[] }>(
|
||||
requirements: T[] | undefined,
|
||||
workflows: PreparedWorkflow[],
|
||||
): T[] | undefined {
|
||||
if (!requirements) return undefined;
|
||||
|
||||
const importedIds = new Set(workflows.map((workflow) => workflow.sourceWorkflowId));
|
||||
return requirements
|
||||
.map((requirement) => ({
|
||||
...requirement,
|
||||
usedByWorkflows: requirement.usedByWorkflows.filter((id) => importedIds.has(id)),
|
||||
}))
|
||||
.filter((requirement) => requirement.usedByWorkflows.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricts explicit credential bindings to those a scope's requirements declare. A project package
|
||||
* shares one binding map across every project, but each project only sees its own requirements — without
|
||||
* this, a binding for a credential used solely in another project looks orphaned and blocks the import.
|
||||
*/
|
||||
export function scopeCredentialBindingsToRequirements(
|
||||
bindings: ImportBindingMap | undefined,
|
||||
requirements: PackageCredentialRequirement[] | undefined,
|
||||
): ImportBindingMap | undefined {
|
||||
if (!bindings) return undefined;
|
||||
|
||||
const requirementIds = new Set((requirements ?? []).map((requirement) => requirement.id));
|
||||
return new Map([...bindings].filter(([sourceId]) => requirementIds.has(sourceId)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import type { EventService } from '@/events/event.service';
|
||||
|
||||
import type { CredentialBindingRequest } from '../entities/credential/credential.types';
|
||||
import type { DataTableImportRequest } from '../entities/data-table/data-table.types';
|
||||
import type { WorkflowImportOutcome } from '../entities/workflow/workflow-import.types';
|
||||
import type { ImportContext, ImportPackageRequest } from '../n8n-packages.types';
|
||||
import type { ImportOrchestrationResult } from './import-orchestrator';
|
||||
import type { PackageManifest } from '../spec/manifest.schema';
|
||||
|
||||
export interface PackageImportScope {
|
||||
context: ImportContext;
|
||||
imported: ImportOrchestrationResult;
|
||||
credentialRequest: CredentialBindingRequest;
|
||||
dataTableRequest: DataTableImportRequest;
|
||||
}
|
||||
|
||||
export function emitPackageImportedEvent(
|
||||
eventService: EventService,
|
||||
params: {
|
||||
request: ImportPackageRequest;
|
||||
manifest: PackageManifest;
|
||||
scopes: PackageImportScope[];
|
||||
},
|
||||
): void {
|
||||
const { request, manifest, scopes } = params;
|
||||
|
||||
const workflowOutcomes = scopes.flatMap(({ imported }) => imported.workflowOutcomes);
|
||||
const credentialResults = scopes.map(({ imported }) => imported.credentialResult);
|
||||
const importedWorkflows = workflowOutcomes.filter(({ status }) => status !== 'skipped');
|
||||
const countByStatus = (status: WorkflowImportOutcome['status']) =>
|
||||
workflowOutcomes.filter((outcome) => outcome.status === status).length;
|
||||
const credentialRequirements = scopes.reduce(
|
||||
(total, { credentialRequest }) => total + (credentialRequest.requirements?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const matchedCredentialIds = credentialResults.flatMap(({ matched, bindings }) =>
|
||||
matched.map((sourceId) => bindings.get(sourceId)!),
|
||||
);
|
||||
const createdCredentialIds = credentialResults.flatMap(({ stubbed, bindings }) =>
|
||||
stubbed.map((sourceId) => bindings.get(sourceId)!),
|
||||
);
|
||||
|
||||
const dataTablePlans = scopes.map(({ imported }) => imported.dataTablePlan);
|
||||
const dataTableRequirements = scopes.reduce(
|
||||
(total, { dataTableRequest }) => total + (dataTableRequest.requirements?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
const dataTablesMatched = dataTablePlans.reduce((total, plan) => total + plan.matchedCount, 0);
|
||||
const dataTablesCreated = dataTablePlans.reduce(
|
||||
(total, plan) => total + plan.creations.length,
|
||||
0,
|
||||
);
|
||||
|
||||
const folderId = scopes.length === 1 ? scopes[0].context.folderId : null;
|
||||
|
||||
eventService.emit('n8n-package-imported', {
|
||||
user: request.user,
|
||||
projectIds: scopes.map(({ context }) => context.projectId),
|
||||
folderId,
|
||||
workflowIds: importedWorkflows.map(({ workflow }) => workflow.id),
|
||||
options: {
|
||||
workflowConflictPolicy: request.workflowConflictPolicy,
|
||||
workflowIdPolicy: request.workflowIdPolicy,
|
||||
credentialMatchingMode: request.credentialMatchingMode,
|
||||
credentialMissingMode: request.credentialMissingMode,
|
||||
workflowPublishingPolicy: request.workflowPublishingPolicy,
|
||||
dataTableMatchingMode: request.dataTableMatchingMode,
|
||||
dataTableMissingMode: request.dataTableMissingMode,
|
||||
dataTableSchemaConflictPolicy: request.dataTableSchemaConflictPolicy,
|
||||
},
|
||||
packageSourceId: manifest.sourceId,
|
||||
packageVersion: manifest.packageFormatVersion,
|
||||
credentialIds: {
|
||||
matched: matchedCredentialIds,
|
||||
created: createdCredentialIds,
|
||||
updated: [],
|
||||
},
|
||||
counts: {
|
||||
workflows: {
|
||||
created: countByStatus('created'),
|
||||
updated: countByStatus('updated'),
|
||||
skipped: countByStatus('skipped'),
|
||||
},
|
||||
credentials: {
|
||||
matched: matchedCredentialIds.length,
|
||||
created: createdCredentialIds.length,
|
||||
requirements: credentialRequirements,
|
||||
},
|
||||
dataTables: {
|
||||
matched: dataTablesMatched,
|
||||
created: dataTablesCreated,
|
||||
requirements: dataTableRequirements,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ import { packageManifestSchema } from '../spec/manifest.schema';
|
|||
import { serializedDataTableSchema } from '../spec/serialized/data-table.schema';
|
||||
import type { SerializedDataTable } from '../spec/serialized/data-table.schema';
|
||||
import { serializedFolderSchema, type SerializedFolder } from '../spec/serialized/folder.schema';
|
||||
import { serializedProjectSchema } from '../spec/serialized/project.schema';
|
||||
import { serializedProjectSchema, type SerializedProject } from '../spec/serialized/project.schema';
|
||||
import type { SerializedWorkflow } from '../spec/serialized/workflow.schema';
|
||||
|
||||
/**
|
||||
|
|
@ -180,20 +180,30 @@ export class N8nPackageParser {
|
|||
const path = `${entry.target}/project.json`;
|
||||
const wire = await this.readJson(reader, path, 'project');
|
||||
|
||||
let project: SerializedProject;
|
||||
try {
|
||||
const project = serializedProjectSchema.parse(wire);
|
||||
return {
|
||||
sourceProjectId: project.id,
|
||||
name: project.name,
|
||||
...(project.description !== undefined ? { description: project.description } : {}),
|
||||
...(project.icon !== undefined ? { icon: project.icon } : {}),
|
||||
};
|
||||
project = serializedProjectSchema.parse(wire);
|
||||
} catch (cause) {
|
||||
if (cause instanceof ZodError) {
|
||||
throw new UserError(`Package project file at ${path} failed schema validation.`, { cause });
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
|
||||
// Project contents scope by manifest id, but the project is created/matched under project.json's
|
||||
// id — a mismatch would import folders and workflows into the wrong project.
|
||||
if (project.id !== entry.id) {
|
||||
throw new UserError(
|
||||
`Package project at ${path} declares id "${project.id}" but the manifest lists it as "${entry.id}".`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
sourceProjectId: project.id,
|
||||
name: project.name,
|
||||
...(project.description !== undefined ? { description: project.description } : {}),
|
||||
...(project.icon !== undefined ? { icon: project.icon } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async readJson<T = unknown>(
|
||||
|
|
|
|||
|
|
@ -1,25 +1,48 @@
|
|||
import { LicenseState } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
|
||||
import type { CredentialBindingRequest } from '../entities/credential/credential.types';
|
||||
import type { DataTableImportRequest } from '../entities/data-table/data-table.types';
|
||||
import { ProjectImporter } from '../entities/project/project-importer';
|
||||
import type { PackageReader } from '../io/package-reader';
|
||||
import { createBindings } from '../n8n-packages.types';
|
||||
import type { ImportPackageRequest, ImportResult } from '../n8n-packages.types';
|
||||
import type {
|
||||
BlockingIssue,
|
||||
ImportedFolderSummary,
|
||||
ImportedWorkflowSummary,
|
||||
ImportPackageRequest,
|
||||
ImportResult,
|
||||
PackageImportBindings,
|
||||
} from '../n8n-packages.types';
|
||||
import { mergeBindings } from '../n8n-packages.types';
|
||||
import { toImportBlockedError } from './import-blocked.error';
|
||||
import {
|
||||
ImportOrchestrator,
|
||||
type ImportOrchestrationInput,
|
||||
type ImportPlan,
|
||||
} from './import-orchestrator';
|
||||
import {
|
||||
assertPackageImportApiKeyScopes,
|
||||
buildImportResult,
|
||||
identifyRequirements,
|
||||
scopeCredentialBindingsToRequirements,
|
||||
toImportedWorkflowSummaries,
|
||||
toPackageSummary,
|
||||
} from './import-result';
|
||||
import { emitPackageImportedEvent, type PackageImportScope } from './import-telemetry';
|
||||
import { N8nPackageParser } from './n8n-package-parser';
|
||||
import type { PackageManifest } from '../spec/manifest.schema';
|
||||
import type { ManifestEntry, PackageManifest } from '../spec/manifest.schema';
|
||||
|
||||
/**
|
||||
* Imports a package containing projects into the target instance.
|
||||
**/
|
||||
@Service()
|
||||
export class ProjectPackageImporter {
|
||||
constructor(
|
||||
private readonly packageParser: N8nPackageParser,
|
||||
private readonly projectImporter: ProjectImporter,
|
||||
private readonly importOrchestrator: ImportOrchestrator,
|
||||
private readonly eventService: EventService,
|
||||
private readonly licenseState: LicenseState,
|
||||
) {}
|
||||
|
||||
async import(
|
||||
|
|
@ -27,19 +50,138 @@ export class ProjectPackageImporter {
|
|||
reader: PackageReader,
|
||||
manifest: PackageManifest,
|
||||
): Promise<ImportResult> {
|
||||
assertPackageImportApiKeyScopes(request.apiKeyScopes, ['project:create', 'project:update']);
|
||||
this.assertAdequatePermissions(request, manifest);
|
||||
|
||||
const projects = await this.packageParser.getProjects(reader);
|
||||
const plan = await this.projectImporter.plan(request.user, projects);
|
||||
const projectSummaries = await this.projectImporter.apply(request.user, plan);
|
||||
const projectPlan = await this.projectImporter.plan(request.user, projects);
|
||||
// Projects the user is creating (vs matching an existing one). They will be admin of these,
|
||||
// so publish is always allowed and the project need not exist while its contents are planned.
|
||||
const pendingCreateIds = new Set(
|
||||
projectPlan.filter((item) => item.action === 'create').map((item) => item.sourceProjectId),
|
||||
);
|
||||
|
||||
// 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 }> = [];
|
||||
const blockingIssues: BlockingIssue[] = [];
|
||||
for (const project of manifest.projects ?? []) {
|
||||
const input = await this.buildImportContextForProject(
|
||||
request,
|
||||
reader,
|
||||
manifest,
|
||||
project,
|
||||
pendingCreateIds.has(project.id),
|
||||
);
|
||||
const plan = await this.importOrchestrator.plan(input);
|
||||
planned.push({ project, plan });
|
||||
blockingIssues.push(...plan.blockingIssues);
|
||||
}
|
||||
if (blockingIssues.length > 0) {
|
||||
throw toImportBlockedError(blockingIssues);
|
||||
}
|
||||
|
||||
const projectSummaries = await this.projectImporter.apply(request.user, projectPlan);
|
||||
|
||||
const workflows: ImportedWorkflowSummary[] = [];
|
||||
const folders: ImportedFolderSummary[] = [];
|
||||
const scopedBindings: PackageImportBindings[] = [];
|
||||
const matched: string[] = [];
|
||||
const stubbed: string[] = [];
|
||||
const scopes: PackageImportScope[] = [];
|
||||
|
||||
for (const { project, plan } of planned) {
|
||||
const imported = await this.importOrchestrator.apply(plan);
|
||||
workflows.push(...toImportedWorkflowSummaries(imported.workflowOutcomes, project.id));
|
||||
folders.push(...imported.folderSummaries);
|
||||
scopedBindings.push(imported.bindings);
|
||||
matched.push(...imported.credentialResult.matched);
|
||||
stubbed.push(...imported.credentialResult.stubbed);
|
||||
scopes.push({
|
||||
context: plan.input.context,
|
||||
imported,
|
||||
credentialRequest: plan.input.credentialRequest,
|
||||
dataTableRequest: plan.input.dataTableRequest,
|
||||
});
|
||||
}
|
||||
|
||||
emitPackageImportedEvent(this.eventService, { request, manifest, scopes });
|
||||
|
||||
return buildImportResult({
|
||||
package: toPackageSummary(manifest),
|
||||
projectId: null,
|
||||
workflows: [],
|
||||
folders: [],
|
||||
workflows,
|
||||
folders,
|
||||
projects: projectSummaries,
|
||||
bindings: createBindings(),
|
||||
bindings: mergeBindings(...scopedBindings),
|
||||
credentials: { matched, stubbed },
|
||||
});
|
||||
}
|
||||
|
||||
private async buildImportContextForProject(
|
||||
request: ImportPackageRequest,
|
||||
reader: PackageReader,
|
||||
manifest: PackageManifest,
|
||||
project: ManifestEntry,
|
||||
projectPendingCreation: boolean,
|
||||
): Promise<ImportOrchestrationInput> {
|
||||
const basePrefix = `${project.target}/`;
|
||||
const folders = await this.packageParser.getFolders(reader, basePrefix);
|
||||
const workflows = await this.packageParser.getWorkflows(reader, basePrefix);
|
||||
|
||||
// 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).
|
||||
const requirements = identifyRequirements(manifest.requirements?.credentials, workflows);
|
||||
const credentialRequest: CredentialBindingRequest = {
|
||||
requirements,
|
||||
matchingMode: request.credentialMatchingMode,
|
||||
missingMode: request.credentialMissingMode,
|
||||
credentialBindings: scopeCredentialBindingsToRequirements(
|
||||
request.bindings?.credentials,
|
||||
requirements,
|
||||
),
|
||||
};
|
||||
|
||||
const dataTableRequest: DataTableImportRequest = {
|
||||
requirements: identifyRequirements(manifest.requirements?.dataTables, workflows),
|
||||
packageDataTables: await this.packageParser.getDataTables(reader),
|
||||
matchingMode: request.dataTableMatchingMode,
|
||||
missingMode: request.dataTableMissingMode,
|
||||
schemaConflictPolicy: request.dataTableSchemaConflictPolicy,
|
||||
};
|
||||
|
||||
return {
|
||||
context: {
|
||||
user: request.user,
|
||||
projectId: project.id,
|
||||
folderId: null,
|
||||
},
|
||||
folders,
|
||||
workflows,
|
||||
credentialRequest,
|
||||
dataTableRequest,
|
||||
options: request,
|
||||
projectPendingCreation,
|
||||
};
|
||||
}
|
||||
|
||||
private assertAdequatePermissions(
|
||||
request: ImportPackageRequest,
|
||||
manifest: PackageManifest,
|
||||
): void {
|
||||
// A project package can create new projects or update matched ones (by source id), so require both —
|
||||
// mirroring the folder create+update assertion below.
|
||||
assertPackageImportApiKeyScopes(request.apiKeyScopes, ['project:create', 'project:update']);
|
||||
|
||||
if ((manifest.folders?.length ?? 0) > 0) {
|
||||
if (!this.licenseState.isLicensed('feat:folders')) {
|
||||
throw new ForbiddenError(
|
||||
'Your license does not allow folders. Importing a package with folders requires a license that supports folders.',
|
||||
);
|
||||
}
|
||||
assertPackageImportApiKeyScopes(request.apiKeyScopes, ['folder:create', 'folder:update']);
|
||||
}
|
||||
|
||||
if ((manifest.workflows?.length ?? 0) > 0) {
|
||||
assertPackageImportApiKeyScopes(request.apiKeyScopes, ['workflow:import']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,20 +12,19 @@ import { ProjectService } from '@/services/project.service.ee';
|
|||
|
||||
import type { CredentialBindingRequest } from '../entities/credential/credential.types';
|
||||
import type { DataTableImportRequest } from '../entities/data-table/data-table.types';
|
||||
import type {
|
||||
PreparedWorkflow,
|
||||
WorkflowImportOutcome,
|
||||
} from '../entities/workflow/workflow-import.types';
|
||||
import type { ImportContext, ImportPackageRequest, ImportResult } from '../n8n-packages.types';
|
||||
import type { PackageReader } from '../io/package-reader';
|
||||
import type { PackageManifest } from '../spec/manifest.schema';
|
||||
import { ImportOrchestrator, type ImportOrchestrationResult } from './import-orchestrator';
|
||||
import type { ImportContext, ImportPackageRequest, ImportResult } from '../n8n-packages.types';
|
||||
import { ImportOrchestrator } from './import-orchestrator';
|
||||
import {
|
||||
assertPackageImportApiKeyScopes,
|
||||
buildImportResult,
|
||||
identifyRequirements,
|
||||
toImportedWorkflowSummaries,
|
||||
toPackageSummary,
|
||||
} from './import-result';
|
||||
import { emitPackageImportedEvent } from './import-telemetry';
|
||||
import { N8nPackageParser } from './n8n-package-parser';
|
||||
import type { PackageManifest } from '../spec/manifest.schema';
|
||||
|
||||
/**
|
||||
* Imports loose top-level workflows, their folder shells, and credential & data table deps into a target project.
|
||||
|
|
@ -92,19 +91,15 @@ export class WorkflowPackageImporter {
|
|||
options: request,
|
||||
});
|
||||
|
||||
this.emitImportedEvent(
|
||||
emitPackageImportedEvent(this.eventService, {
|
||||
request,
|
||||
context,
|
||||
manifest,
|
||||
imported,
|
||||
credentialRequest,
|
||||
dataTableRequest,
|
||||
);
|
||||
scopes: [{ context, imported, credentialRequest, dataTableRequest }],
|
||||
});
|
||||
|
||||
return buildImportResult({
|
||||
package: toPackageSummary(manifest),
|
||||
projectId: context.projectId,
|
||||
workflows: imported.workflowOutcomes,
|
||||
workflows: toImportedWorkflowSummaries(imported.workflowOutcomes, context.projectId),
|
||||
folders: imported.folderSummaries,
|
||||
projects: [],
|
||||
bindings: imported.bindings,
|
||||
|
|
@ -123,65 +118,6 @@ export class WorkflowPackageImporter {
|
|||
}
|
||||
}
|
||||
|
||||
private emitImportedEvent(
|
||||
request: ImportPackageRequest,
|
||||
context: ImportContext,
|
||||
manifest: PackageManifest,
|
||||
imported: ImportOrchestrationResult,
|
||||
credentialRequest: CredentialBindingRequest,
|
||||
dataTableRequest: DataTableImportRequest,
|
||||
): void {
|
||||
const { workflowOutcomes, credentialResult, dataTablePlan } = imported;
|
||||
const importedWorkflows = workflowOutcomes.filter(({ status }) => status !== 'skipped');
|
||||
const countByStatus = (status: WorkflowImportOutcome['status']) =>
|
||||
workflowOutcomes.filter((outcome) => outcome.status === status).length;
|
||||
|
||||
this.eventService.emit('n8n-package-imported', {
|
||||
user: context.user,
|
||||
projectId: context.projectId,
|
||||
folderId: context.folderId,
|
||||
workflowIds: importedWorkflows.map(({ workflow }) => workflow.id),
|
||||
options: {
|
||||
workflowConflictPolicy: request.workflowConflictPolicy,
|
||||
workflowIdPolicy: request.workflowIdPolicy,
|
||||
credentialMatchingMode: request.credentialMatchingMode,
|
||||
credentialMissingMode: request.credentialMissingMode,
|
||||
workflowPublishingPolicy: request.workflowPublishingPolicy,
|
||||
dataTableMatchingMode: request.dataTableMatchingMode,
|
||||
dataTableMissingMode: request.dataTableMissingMode,
|
||||
dataTableSchemaConflictPolicy: request.dataTableSchemaConflictPolicy,
|
||||
},
|
||||
packageSourceId: manifest.sourceId,
|
||||
packageVersion: manifest.packageFormatVersion,
|
||||
credentialIds: {
|
||||
matched: credentialResult.matched.map(
|
||||
(sourceId) => credentialResult.bindings.get(sourceId)!,
|
||||
),
|
||||
created: credentialResult.stubbed.map(
|
||||
(sourceId) => credentialResult.bindings.get(sourceId)!,
|
||||
),
|
||||
updated: [],
|
||||
},
|
||||
counts: {
|
||||
workflows: {
|
||||
created: countByStatus('created'),
|
||||
updated: countByStatus('updated'),
|
||||
skipped: countByStatus('skipped'),
|
||||
},
|
||||
credentials: {
|
||||
matched: credentialResult.matched.length,
|
||||
created: credentialResult.stubbed.length,
|
||||
requirements: credentialRequest.requirements?.length ?? 0,
|
||||
},
|
||||
dataTables: {
|
||||
matched: dataTablePlan.matchedCount,
|
||||
created: dataTablePlan.creations.length,
|
||||
requirements: dataTableRequest.requirements?.length ?? 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async findImportLocation(
|
||||
user: User,
|
||||
projectId: string | undefined,
|
||||
|
|
@ -236,19 +172,3 @@ export class WorkflowPackageImporter {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps only the requirements used by the imported workflows, trimming `usedByWorkflows` to match. */
|
||||
function identifyRequirements<T extends { usedByWorkflows: string[] }>(
|
||||
requirements: T[] | undefined,
|
||||
workflows: PreparedWorkflow[],
|
||||
): T[] | undefined {
|
||||
if (!requirements) return undefined;
|
||||
|
||||
const importedIds = new Set(workflows.map((workflow) => workflow.sourceWorkflowId));
|
||||
return requirements
|
||||
.map((requirement) => ({
|
||||
...requirement,
|
||||
usedByWorkflows: requirement.usedByWorkflows.filter((id) => importedIds.has(id)),
|
||||
}))
|
||||
.filter((requirement) => requirement.usedByWorkflows.length > 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,18 @@ describe('WorkflowPublisher', () => {
|
|||
expect(projectService.getProjectWithScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing for a pending-create project even under publish-all', async () => {
|
||||
// The project does not exist yet; its creator will be admin, so there is nothing to check.
|
||||
await publisher.assertCanPublish(
|
||||
user,
|
||||
'new-project',
|
||||
WorkflowPublishingPolicy.PublishAll,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(projectService.getProjectWithScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes when the user can publish in the target project', async () => {
|
||||
projectService.getProjectWithScope.mockResolvedValue(mock<Project>({ id: 'project-1' }));
|
||||
|
||||
|
|
|
|||
|
|
@ -40,16 +40,25 @@ export class WorkflowPublisher {
|
|||
* Fail the import before any writes when {@link WorkflowPublishingPolicy.PublishAll}
|
||||
* is selected and the actor lacks `workflow:publish`. Other policies skip this check;
|
||||
* publish permission is checked per workflow in workflowService
|
||||
*
|
||||
* `projectPendingCreation` lets this run before the target project exists: a project the
|
||||
* user is importing as new will be created with them as admin, so they can always publish
|
||||
* in it and there is nothing to look up yet.
|
||||
*/
|
||||
async assertCanPublish(
|
||||
user: User,
|
||||
projectId: string,
|
||||
policy: WorkflowPublishingPolicy,
|
||||
projectPendingCreation = false,
|
||||
): Promise<void> {
|
||||
if (policy !== WorkflowPublishingPolicy.PublishAll) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectPendingCreation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const project = await this.projectService.getProjectWithScope(user, projectId, [
|
||||
'workflow:publish',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -269,6 +269,14 @@ export function createBindings(seed: Partial<PackageImportBindings> = {}): Packa
|
|||
};
|
||||
}
|
||||
|
||||
/** Combines per-scope binding maps into one — used when a project package imports several scopes. */
|
||||
export function mergeBindings(...bindings: PackageImportBindings[]): PackageImportBindings {
|
||||
return {
|
||||
workflows: new Map(bindings.flatMap(({ workflows }) => [...workflows])),
|
||||
credentials: new Map(bindings.flatMap(({ credentials }) => [...credentials])),
|
||||
};
|
||||
}
|
||||
|
||||
/** Plain-object form of {@link PackageImportBindings}, suitable for JSON responses. */
|
||||
export type SerializedBindings = Record<keyof PackageImportBindings, Record<string, string>>;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user