mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-29 19:10:43 +08:00
feat(API): Support exporting projects via the public API (#33084)
Co-authored-by: James Gee <1285296+geemanjs@users.noreply.github.com>
This commit is contained in:
co-authored by
James Gee
parent
f008844d75
commit
0f46be02aa
@@ -213,11 +213,11 @@ export {
|
||||
export { DownloadDataTableCsvQueryDto } from './data-table/download-data-table-csv-query.dto';
|
||||
export { ImportCsvToDataTableDto } from './data-table/import-csv-to-data-table.dto';
|
||||
|
||||
export { ExportWorkflowsRequestDto } from './packages/export-workflows-request.dto';
|
||||
export {
|
||||
ImportPackageRequestDto,
|
||||
IMPORT_PACKAGE_REQUEST_FORM_FIELDS,
|
||||
} from './packages/import-package-request.dto';
|
||||
export { ExportPackageRequestDto } from './packages/export-package-request.dto';
|
||||
|
||||
export * from './evaluations';
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ExportPackageRequestDto } from '../export-package-request.dto';
|
||||
|
||||
describe('ExportPackageRequestDto', () => {
|
||||
describe('workflowIds', () => {
|
||||
it('accepts a non-empty array of workflow ids', () => {
|
||||
const result = ExportPackageRequestDto.safeParse({ workflowIds: ['wf-1', 'wf-2'] });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts up to 300 workflow ids', () => {
|
||||
const workflowIds = Array.from({ length: 300 }, (_, i) => `wf-${i}`);
|
||||
expect(ExportPackageRequestDto.safeParse({ workflowIds }).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'empty workflowIds array', request: { workflowIds: [] } },
|
||||
{ name: 'both empty arrays', request: { workflowIds: [], projectIds: [] } },
|
||||
{ name: 'empty-string workflow id', request: { workflowIds: [''] } },
|
||||
{ name: 'whitespace-only workflow id', request: { workflowIds: [' '] } },
|
||||
{ name: 'non-string workflow id', request: { workflowIds: [123] } },
|
||||
{
|
||||
name: 'more than 300 workflow ids',
|
||||
request: { workflowIds: Array.from({ length: 301 }, (_, i) => `wf-${i}`) },
|
||||
},
|
||||
])('rejects $name', ({ request }) => {
|
||||
expect(ExportPackageRequestDto.safeParse(request).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectIds', () => {
|
||||
it('accepts a non-empty array of project ids', () => {
|
||||
const result = ExportPackageRequestDto.safeParse({ projectIds: ['project-1'] });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts up to 300 project ids', () => {
|
||||
const projectIds = Array.from({ length: 300 }, (_, i) => `project-${i}`);
|
||||
expect(ExportPackageRequestDto.safeParse({ projectIds }).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'empty projectIds array', request: { projectIds: [] } },
|
||||
{ name: 'both empty arrays', request: { workflowIds: [], projectIds: [] } },
|
||||
{ name: 'empty-string project id', request: { projectIds: [''] } },
|
||||
{ name: 'whitespace-only project id', request: { projectIds: [' '] } },
|
||||
{ name: 'non-string project id', request: { projectIds: [123] } },
|
||||
{
|
||||
name: 'more than 300 project ids',
|
||||
request: { projectIds: Array.from({ length: 301 }, (_, i) => `project-${i}`) },
|
||||
},
|
||||
])('rejects $name', ({ request }) => {
|
||||
expect(ExportPackageRequestDto.safeParse(request).success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import { ExportWorkflowsRequestDto } from '../export-workflows-request.dto';
|
||||
|
||||
describe('ExportWorkflowsRequestDto', () => {
|
||||
it('accepts a non-empty array of workflow ids', () => {
|
||||
const result = ExportWorkflowsRequestDto.safeParse({ workflowIds: ['wf-1', 'wf-2'] });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts up to 300 workflow ids', () => {
|
||||
const workflowIds = Array.from({ length: 300 }, (_, i) => `wf-${i}`);
|
||||
expect(ExportWorkflowsRequestDto.safeParse({ workflowIds }).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'missing workflowIds', request: {} },
|
||||
{ name: 'empty workflowIds array', request: { workflowIds: [] } },
|
||||
{ name: 'empty-string id', request: { workflowIds: [''] } },
|
||||
{ name: 'whitespace-only id', request: { workflowIds: [' '] } },
|
||||
{ name: 'non-string id', request: { workflowIds: [123] } },
|
||||
{
|
||||
name: 'more than 300 workflow ids',
|
||||
request: { workflowIds: Array.from({ length: 301 }, (_, i) => `wf-${i}`) },
|
||||
},
|
||||
])('rejects $name', ({ request }) => {
|
||||
expect(ExportWorkflowsRequestDto.safeParse(request).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class ExportPackageRequestDto extends Z.class({
|
||||
workflowIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(),
|
||||
projectIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(),
|
||||
}) {}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class ExportWorkflowsRequestDto extends Z.class({
|
||||
workflowIds: z.array(z.string().trim().min(1)).min(1).max(300),
|
||||
}) {}
|
||||
@@ -9,19 +9,25 @@ Export and import workflows as portable n8n packages (`.n8np` archives).
|
||||
|
||||
## `package export`
|
||||
|
||||
Export one or more workflows into a gzipped `.n8np` archive written to disk.
|
||||
Export workflows or projects into a gzipped `.n8np` archive written to disk.
|
||||
Provide workflow IDs or project IDs, but not both.
|
||||
|
||||
```bash
|
||||
n8n-cli package export --workflow-id=abc --output=export.n8np
|
||||
n8n-cli package export -w abc -w def -o team.n8np
|
||||
n8n-cli package export --project-id=abc -o project.n8np
|
||||
n8n-cli package export -p abc -p def -o projects.n8np
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-w, --workflow-id` | Workflow ID to include. Repeat the flag to export several. (required) |
|
||||
| `-w, --workflow-id` | Workflow ID to include. Repeat the flag to export several. |
|
||||
| `-p, --project-id` | Project ID to include. Repeat the flag to export several. |
|
||||
| `-o, --output` | File to write the package to. Defaults to `export.n8np`. |
|
||||
|
||||
Requires the API key to hold the `workflow:export` scope.
|
||||
Provide at least one `--workflow-id` or `--project-id`. Requires the API key to
|
||||
hold `workflow:export` when exporting workflows, or `project:export` when
|
||||
exporting projects.
|
||||
|
||||
## `package import`
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('N8nClient packages', () => {
|
||||
it('posts the workflow IDs as JSON and returns the archive bytes', async () => {
|
||||
fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1, 2, 3])));
|
||||
|
||||
const result = await client.exportPackage(['a', 'b']);
|
||||
const result = await client.exportPackage({ workflowIds: ['a', 'b'] });
|
||||
|
||||
expect(Buffer.isBuffer(result)).toBe(true);
|
||||
expect(result.equals(Buffer.from([1, 2, 3]))).toBe(true);
|
||||
@@ -55,6 +55,19 @@ describe('N8nClient packages', () => {
|
||||
expect(url).toBe('https://n8n.example.com/api/v1/n8n-packages/export');
|
||||
expect(init.body).toBe(JSON.stringify({ workflowIds: ['a', 'b'] }));
|
||||
});
|
||||
|
||||
it('posts the project IDs as JSON and returns the archive bytes', async () => {
|
||||
fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([4, 5, 6])));
|
||||
|
||||
const result = await client.exportPackage({ projectIds: ['proj-1', 'proj-2'] });
|
||||
|
||||
expect(Buffer.isBuffer(result)).toBe(true);
|
||||
expect(result.equals(Buffer.from([4, 5, 6]))).toBe(true);
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('https://n8n.example.com/api/v1/n8n-packages/export');
|
||||
expect(init.body).toBe(JSON.stringify({ projectIds: ['proj-1', 'proj-2'] }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('importPackage', () => {
|
||||
|
||||
@@ -11,9 +11,12 @@ const mockedWriteFileSync = vi.mocked(fs.writeFileSync);
|
||||
|
||||
/** The command methods we stub to isolate the file-writing behaviour from oclif/networking. */
|
||||
interface ExportInternals {
|
||||
parse: () => Promise<{ flags: { workflowId: string[]; output: string } }>;
|
||||
parse: () => Promise<{
|
||||
flags: { workflowId?: string[]; projectId?: string[]; output: string };
|
||||
}>;
|
||||
getClient: () => N8nClient;
|
||||
succeed: () => void;
|
||||
error: (message: string) => never;
|
||||
}
|
||||
|
||||
describe('package export command', () => {
|
||||
@@ -21,23 +24,64 @@ describe('package export command', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('writes the bytes returned by the client to the --output path', async () => {
|
||||
it('writes workflow export bytes returned by the client to the --output path', async () => {
|
||||
const archive = Buffer.from([1, 2, 3]);
|
||||
const exportPackage = vi.fn().mockResolvedValue(archive);
|
||||
|
||||
const command = new PackageExport([], {} as Config);
|
||||
const internals = command as unknown as ExportInternals;
|
||||
// Bypass oclif arg parsing, connection setup, and the success/exit path; the
|
||||
// behaviour under test is purely that the client's bytes land at flags.output.
|
||||
vi.spyOn(internals, 'parse').mockResolvedValue({
|
||||
flags: { workflowId: ['wf-1', 'wf-2'], output: '/tmp/team.n8np' },
|
||||
});
|
||||
vi.spyOn(internals, 'getClient').mockReturnValue({ exportPackage } as unknown as N8nClient);
|
||||
vi.spyOn(internals, 'succeed').mockImplementation(() => {});
|
||||
vi.spyOn(internals, 'error').mockImplementation((message: string) => {
|
||||
throw new Error(message);
|
||||
});
|
||||
|
||||
await command.run();
|
||||
|
||||
expect(exportPackage).toHaveBeenCalledWith(['wf-1', 'wf-2']);
|
||||
expect(exportPackage).toHaveBeenCalledWith({ workflowIds: ['wf-1', 'wf-2'] });
|
||||
expect(mockedWriteFileSync).toHaveBeenCalledWith('/tmp/team.n8np', archive);
|
||||
});
|
||||
|
||||
it('writes project export bytes returned by the client to the --output path', async () => {
|
||||
const archive = Buffer.from([4, 5, 6]);
|
||||
const exportPackage = vi.fn().mockResolvedValue(archive);
|
||||
|
||||
const command = new PackageExport([], {} as Config);
|
||||
const internals = command as unknown as ExportInternals;
|
||||
vi.spyOn(internals, 'parse').mockResolvedValue({
|
||||
flags: { projectId: ['proj-1', 'proj-2'], output: '/tmp/projects.n8np' },
|
||||
});
|
||||
vi.spyOn(internals, 'getClient').mockReturnValue({ exportPackage } as unknown as N8nClient);
|
||||
vi.spyOn(internals, 'succeed').mockImplementation(() => {});
|
||||
vi.spyOn(internals, 'error').mockImplementation((message: string) => {
|
||||
throw new Error(message);
|
||||
});
|
||||
|
||||
await command.run();
|
||||
|
||||
expect(exportPackage).toHaveBeenCalledWith({ projectIds: ['proj-1', 'proj-2'] });
|
||||
expect(mockedWriteFileSync).toHaveBeenCalledWith('/tmp/projects.n8np', archive);
|
||||
});
|
||||
|
||||
it('rejects providing both workflow and project IDs', async () => {
|
||||
const command = new PackageExport([], {} as Config);
|
||||
const internals = command as unknown as ExportInternals;
|
||||
vi.spyOn(internals, 'parse').mockResolvedValue({
|
||||
flags: {
|
||||
workflowId: ['wf-1'],
|
||||
projectId: ['proj-1'],
|
||||
output: '/tmp/export.n8np',
|
||||
},
|
||||
});
|
||||
vi.spyOn(internals, 'error').mockImplementation((message: string) => {
|
||||
throw new Error(message);
|
||||
});
|
||||
|
||||
await expect(command.run()).rejects.toThrow(
|
||||
'Provide either --workflow-id or --project-id, not both',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -421,9 +421,11 @@ export class N8nClient {
|
||||
|
||||
// ─── Packages (beta) ───────────────────────────────────────────
|
||||
|
||||
async exportPackage(workflowIds: string[]): Promise<Buffer> {
|
||||
async exportPackage(
|
||||
fields: { workflowIds: string[] } | { projectIds: string[] },
|
||||
): Promise<Buffer> {
|
||||
return await this.request<Buffer>('POST', '/n8n-packages/export', {
|
||||
body: { workflowIds },
|
||||
body: fields,
|
||||
responseType: 'binary',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import { toPackagesError } from './shared';
|
||||
import { BaseCommand } from '../../base-command';
|
||||
|
||||
export default class PackageExport extends BaseCommand {
|
||||
static override description = 'Export workflows as an n8n package (.n8np)';
|
||||
static override description = 'Export workflows or projects as an n8n package (.n8np)';
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> package export --workflow-id=abc --output=export.n8np',
|
||||
'<%= config.bin %> package export -w abc -w def -o team.n8np',
|
||||
'<%= config.bin %> package export --project-id=abc -o project.n8np',
|
||||
'<%= config.bin %> package export -p abc -p def -o projects.n8np',
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
@@ -18,9 +20,14 @@ export default class PackageExport extends BaseCommand {
|
||||
char: 'w',
|
||||
description: 'Workflow ID to include (repeat for multiple)',
|
||||
multiple: true,
|
||||
required: true,
|
||||
aliases: ['workflow-id'],
|
||||
}),
|
||||
projectId: Flags.string({
|
||||
char: 'p',
|
||||
description: 'Project ID to include (repeat for multiple)',
|
||||
multiple: true,
|
||||
aliases: ['project-id'],
|
||||
}),
|
||||
output: Flags.string({
|
||||
char: 'o',
|
||||
description: 'File to write the package to',
|
||||
@@ -30,18 +37,39 @@ export default class PackageExport extends BaseCommand {
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { flags } = await this.parse(PackageExport);
|
||||
const workflowIds = flags.workflowId ?? [];
|
||||
const projectIds = flags.projectId ?? [];
|
||||
|
||||
if (workflowIds.length > 0 && projectIds.length > 0) {
|
||||
this.error('Provide either --workflow-id or --project-id, not both');
|
||||
}
|
||||
if (workflowIds.length === 0 && projectIds.length === 0) {
|
||||
this.error('At least one --workflow-id or --project-id is required');
|
||||
}
|
||||
|
||||
await this.execute(async () => {
|
||||
const client = this.getClient(flags);
|
||||
let archive: Buffer;
|
||||
try {
|
||||
archive = await client.exportPackage(flags.workflowId);
|
||||
archive = await client.exportPackage(
|
||||
workflowIds.length > 0 ? { workflowIds } : { projectIds },
|
||||
);
|
||||
} catch (error) {
|
||||
throw toPackagesError(error);
|
||||
}
|
||||
fs.writeFileSync(flags.output, archive);
|
||||
this.succeed(`Exported ${flags.workflowId.length} workflow(s) to ${flags.output}`, flags, {
|
||||
|
||||
if (workflowIds.length > 0) {
|
||||
this.succeed(`Exported ${workflowIds.length} workflow(s) to ${flags.output}`, flags, {
|
||||
output: flags.output,
|
||||
workflowIds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.succeed(`Exported ${projectIds.length} project(s) to ${flags.output}`, flags, {
|
||||
output: flags.output,
|
||||
workflowIds: flags.workflowId,
|
||||
projectIds,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = `
|
||||
"project:update",
|
||||
"project:delete",
|
||||
"project:list",
|
||||
"project:export",
|
||||
"project:*",
|
||||
"saml:manage",
|
||||
"saml:*",
|
||||
|
||||
@@ -23,7 +23,7 @@ export const RESOURCES = {
|
||||
license: ['manage'] as const,
|
||||
logStreaming: ['manage'] as const,
|
||||
orchestration: ['read', 'list'] as const,
|
||||
project: [...DEFAULT_OPERATIONS] as const,
|
||||
project: [...DEFAULT_OPERATIONS, 'export'] as const,
|
||||
saml: ['manage'] as const,
|
||||
securityAudit: ['generate'] as const,
|
||||
securitySettings: ['manage'] as const,
|
||||
@@ -88,7 +88,7 @@ export const API_KEY_RESOURCES = {
|
||||
workflow: [...DEFAULT_OPERATIONS, 'move', 'activate', 'deactivate', 'export', 'import'] as const,
|
||||
variable: ['create', 'update', 'delete', 'list'] as const,
|
||||
securityAudit: ['generate'] as const,
|
||||
project: ['create', 'update', 'delete', 'list'] as const,
|
||||
project: ['create', 'update', 'delete', 'list', 'export'] as const,
|
||||
user: ['read', 'list', 'create', 'changeRole', 'delete'] as const,
|
||||
execution: ['delete', 'read', 'retry', 'list', 'stop'] as const,
|
||||
credential: ['create', 'read', 'update', 'move', 'delete', 'list'] as const,
|
||||
|
||||
@@ -18,6 +18,7 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
|
||||
'project:update',
|
||||
'project:delete',
|
||||
'project:list',
|
||||
'project:export',
|
||||
'variable:create',
|
||||
'variable:delete',
|
||||
'variable:list',
|
||||
@@ -94,6 +95,7 @@ export const MEMBER_API_KEY_SCOPES: ApiKeyScope[] = [
|
||||
'workflow:move',
|
||||
'workflow:activate',
|
||||
'workflow:deactivate',
|
||||
'project:export',
|
||||
'execution:delete',
|
||||
'execution:read',
|
||||
'execution:retry',
|
||||
@@ -147,6 +149,7 @@ export const API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT: ApiKeyScope[] = [
|
||||
'workflow:move',
|
||||
'workflow:activate',
|
||||
'workflow:deactivate',
|
||||
'project:export',
|
||||
'execution:delete',
|
||||
'execution:read',
|
||||
'execution:retry',
|
||||
|
||||
@@ -103,6 +103,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
||||
'project:read',
|
||||
'project:update',
|
||||
'project:delete',
|
||||
'project:export',
|
||||
'insights:list',
|
||||
'insights:read',
|
||||
'folder:move',
|
||||
|
||||
@@ -43,6 +43,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [
|
||||
'project:read',
|
||||
'project:update',
|
||||
'project:delete',
|
||||
'project:export',
|
||||
'folder:create',
|
||||
'folder:read',
|
||||
'folder:update',
|
||||
@@ -99,6 +100,7 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
|
||||
'execution:reveal',
|
||||
'project:list',
|
||||
'project:read',
|
||||
'project:export',
|
||||
'folder:create',
|
||||
'folder:read',
|
||||
'folder:update',
|
||||
@@ -144,6 +146,7 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [
|
||||
'credential:connect',
|
||||
'project:list',
|
||||
'project:read',
|
||||
'project:export',
|
||||
'folder:create',
|
||||
'folder:read',
|
||||
'folder:update',
|
||||
@@ -173,6 +176,7 @@ export const PROJECT_VIEWER_SCOPES: Scope[] = [
|
||||
'credential:read',
|
||||
'project:list',
|
||||
'project:read',
|
||||
'project:export',
|
||||
'workflow:list',
|
||||
'workflow:read',
|
||||
'workflow:export',
|
||||
|
||||
@@ -88,6 +88,10 @@ export const scopeInformation: Partial<Record<Scope, ScopeInformation>> = {
|
||||
displayName: 'Import Workflow',
|
||||
description: 'Allows importing workflows from a portable package into the project.',
|
||||
},
|
||||
'project:export': {
|
||||
displayName: 'Export Project',
|
||||
description: 'Allows including projects in a portable package export.',
|
||||
},
|
||||
'credential:unshare': {
|
||||
displayName: 'Unshare Credential',
|
||||
description: 'Allows removing credential shares.',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { RESOURCES, API_KEY_RESOURCES } from './constants.ee';
|
||||
import { PROJECT_OWNER_ROLE_SLUG } from './constants.ee';
|
||||
import type {
|
||||
assignableGlobalRoleSchema,
|
||||
credentialSharingRoleSchema,
|
||||
@@ -13,7 +14,6 @@ import type {
|
||||
secretsProviderConnectionSharingRoleSchema,
|
||||
assignableProjectRoleSchema,
|
||||
} from './schemas.ee';
|
||||
import { PROJECT_OWNER_ROLE_SLUG } from './constants.ee';
|
||||
import { ALL_API_KEY_SCOPES } from './scope-information';
|
||||
|
||||
export type ScopeInformation = {
|
||||
|
||||
@@ -825,7 +825,12 @@ export class ActiveWorkflowManager {
|
||||
await this.add(workflowId, activationMode, workflowData, { shouldPublish: false });
|
||||
} catch (error) {
|
||||
this.errorReporter.error(error);
|
||||
let lastTimeout = this.queuedActivations[workflowId].lastTimeout;
|
||||
const queuedActivation = this.queuedActivations[workflowId];
|
||||
if (!queuedActivation) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lastTimeout = queuedActivation.lastTimeout;
|
||||
if (!(error instanceof IsolateError) && lastTimeout < WORKFLOW_REACTIVATE_MAX_TIMEOUT) {
|
||||
lastTimeout = Math.min(lastTimeout * 2, WORKFLOW_REACTIVATE_MAX_TIMEOUT);
|
||||
}
|
||||
@@ -841,8 +846,8 @@ export class ActiveWorkflowManager {
|
||||
},
|
||||
);
|
||||
|
||||
this.queuedActivations[workflowId].lastTimeout = lastTimeout;
|
||||
this.queuedActivations[workflowId].timeout = setTimeout(retryFunction, lastTimeout);
|
||||
queuedActivation.lastTimeout = lastTimeout;
|
||||
queuedActivation.timeout = setTimeout(retryFunction, lastTimeout);
|
||||
return;
|
||||
}
|
||||
this.logger.info(`Activation of workflow "${workflowName}" (${workflowId}) was successful!`, {
|
||||
|
||||
@@ -46,14 +46,14 @@ flowchart LR
|
||||
|
||||
### Adding an EXPORT property
|
||||
|
||||
1. **Module** — `n8n-packages.types.ts` `ExportWorkflowsRequest`; implement in
|
||||
`n8n-packages.service.ts` `exportWorkflows` (+ `io/` writer or
|
||||
1. **Module** — `n8n-packages.types.ts` `ExportPackageRequest`; implement in
|
||||
`n8n-packages.service.ts` `exportPackage` (+ `io/` writer or
|
||||
`entities/*/` exporter as needed).
|
||||
2. **DTO** — `@n8n/api-types/src/dto/packages/export-workflows-request.dto.ts`
|
||||
(`ExportWorkflowsRequestDto`, zod).
|
||||
3. **Public API** — `n8n-packages.handler.ts` `exportWorkflows` reads from
|
||||
2. **DTO** — `@n8n/api-types/src/dto/packages/export-package-request.dto.ts`
|
||||
(`ExportPackageRequestDto`, zod).
|
||||
3. **Public API** — `n8n-packages.handler.ts` `exportPackage` reads from
|
||||
`payload.data`; update the **separate** schema file
|
||||
`spec/schemas/exportWorkflowsRequest.yml` (export's request schema is a
|
||||
`spec/schemas/exportPackageRequest.yml` (export's request schema is a
|
||||
`$ref`, unlike import's inline schema).
|
||||
4. **CLI** — `src/client.ts` `exportPackage(...)`,
|
||||
`src/commands/package/export.ts` flag, and the docs/README.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
createTeamProject,
|
||||
getPersonalProject,
|
||||
linkUserToProject,
|
||||
testDb,
|
||||
testModules,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
|
||||
import { createMember, createOwner } from '@test-integration/db/users';
|
||||
|
||||
import { N8nPackagesService } from '../n8n-packages.service';
|
||||
import { FORMAT_VERSION } from '../spec/constants';
|
||||
import { readExport } from './utils/tar-support';
|
||||
|
||||
beforeAll(async () => {
|
||||
await testModules.loadModules(['n8n-packages']);
|
||||
await testDb.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['WorkflowEntity', 'SharedWorkflow', 'ProjectRelation', 'Project']);
|
||||
});
|
||||
|
||||
describe('project package export', () => {
|
||||
let service: N8nPackagesService;
|
||||
|
||||
beforeAll(() => {
|
||||
service = Container.get(N8nPackagesService);
|
||||
});
|
||||
|
||||
async function exportSingleProject(user: User, projectId: string) {
|
||||
const stream = await service.exportPackage({ user, projectIds: [projectId] });
|
||||
return await readExport(stream);
|
||||
}
|
||||
|
||||
async function exportProjects(user: User, projectIds: string[]) {
|
||||
const stream = await service.exportPackage({ user, projectIds });
|
||||
return await readExport(stream);
|
||||
}
|
||||
|
||||
it('emits a tar with manifest.json first and project.json for an empty team project', async () => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('Empty Project', owner);
|
||||
|
||||
const { manifest, entries } = await exportSingleProject(owner, project.id);
|
||||
|
||||
expect(entries[0].name).toBe('manifest.json');
|
||||
expect(manifest).toMatchObject({
|
||||
packageFormatVersion: FORMAT_VERSION,
|
||||
exportedAt: expect.any(String),
|
||||
sourceN8nVersion: expect.any(String),
|
||||
sourceId: expect.any(String),
|
||||
});
|
||||
expect(manifest.projects).toEqual([
|
||||
{ id: project.id, name: 'Empty Project', target: expect.any(String) },
|
||||
]);
|
||||
expect(manifest.workflows).toBeUndefined();
|
||||
|
||||
const projectFile = entries.find(
|
||||
(e) => e.name === `${manifest.projects![0].target}/project.json`,
|
||||
);
|
||||
expect(projectFile).toBeDefined();
|
||||
const serialized = JSON.parse(projectFile!.content.toString());
|
||||
expect(serialized).toEqual({
|
||||
id: project.id,
|
||||
name: 'Empty Project',
|
||||
});
|
||||
});
|
||||
|
||||
it('exports multiple team projects in a single request', async () => {
|
||||
const owner = await createOwner();
|
||||
const firstProject = await createTeamProject('Alpha Project', owner);
|
||||
const secondProject = await createTeamProject('Beta Project', owner);
|
||||
|
||||
const { manifest, entries } = await exportProjects(owner, [secondProject.id, firstProject.id]);
|
||||
|
||||
expect(manifest.projects).toHaveLength(2);
|
||||
expect(manifest.projects).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ id: firstProject.id, name: 'Alpha Project', target: 'projects/alpha-project' },
|
||||
{ id: secondProject.id, name: 'Beta Project', target: 'projects/beta-project' },
|
||||
]),
|
||||
);
|
||||
|
||||
for (const { id, name, target } of manifest.projects!) {
|
||||
const projectFile = entries.find((entry) => entry.name === `${target}/project.json`);
|
||||
expect(projectFile).toBeDefined();
|
||||
expect(JSON.parse(projectFile!.content.toString())).toEqual({ id, name });
|
||||
}
|
||||
});
|
||||
|
||||
it('exports the owner personal project', async () => {
|
||||
const owner = await createOwner();
|
||||
const personalProject = await getPersonalProject(owner);
|
||||
|
||||
const { manifest, entries } = await exportSingleProject(owner, personalProject.id);
|
||||
|
||||
expect(manifest.projects).toEqual([
|
||||
{ id: personalProject.id, name: personalProject.name, target: expect.any(String) },
|
||||
]);
|
||||
|
||||
const projectFile = entries.find(
|
||||
(entry) => entry.name === `${manifest.projects![0].target}/project.json`,
|
||||
);
|
||||
expect(projectFile).toBeDefined();
|
||||
expect(JSON.parse(projectFile!.content.toString())).toEqual({
|
||||
id: personalProject.id,
|
||||
name: personalProject.name,
|
||||
});
|
||||
});
|
||||
|
||||
it('allows a project editor to export an empty team project', async () => {
|
||||
const owner = await createOwner();
|
||||
const editor = await createMember();
|
||||
const project = await createTeamProject('Editor Project', owner);
|
||||
await linkUserToProject(editor, project, 'project:editor');
|
||||
|
||||
const { manifest } = await exportSingleProject(editor, project.id);
|
||||
|
||||
expect(manifest.projects).toEqual([
|
||||
{ id: project.id, name: 'Editor Project', target: expect.any(String) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects export when the user lacks project access', async () => {
|
||||
const owner = await createOwner();
|
||||
const outsider = await createMember();
|
||||
const project = await createTeamProject('Private Project', owner);
|
||||
|
||||
await expect(exportSingleProject(outsider, project.id)).rejects.toThrow(
|
||||
'1 project(s) not found or not accessible. Export aborted.',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects exporting another user's personal project", async () => {
|
||||
const projectOwner = await createMember();
|
||||
const otherUser = await createMember();
|
||||
const personalProject = await getPersonalProject(projectOwner);
|
||||
|
||||
await expect(exportSingleProject(otherUser, personalProject.id)).rejects.toThrow(
|
||||
'1 project(s) not found or not accessible. Export aborted.',
|
||||
);
|
||||
});
|
||||
});
|
||||
+7
-7
@@ -64,7 +64,7 @@ describe('workflow package export — with credentials', () => {
|
||||
credential,
|
||||
});
|
||||
|
||||
const stream = await service.exportWorkflows({ user: owner, workflowIds: [workflow.id] });
|
||||
const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest.credentials).toEqual([
|
||||
@@ -122,7 +122,7 @@ describe('workflow package export — with credentials', () => {
|
||||
credential,
|
||||
});
|
||||
|
||||
const stream = await service.exportWorkflows({
|
||||
const stream = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [wfA.id, wfB.id],
|
||||
});
|
||||
@@ -152,7 +152,7 @@ describe('workflow package export — with credentials', () => {
|
||||
credentialType: 'httpHeaderAuth',
|
||||
});
|
||||
|
||||
const stream = await service.exportWorkflows({ user: owner, workflowIds: [workflow.id] });
|
||||
const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest.credentials).toBeUndefined();
|
||||
@@ -195,7 +195,7 @@ describe('workflow package export — with credentials', () => {
|
||||
// credential was never shared with them. The export must still succeed,
|
||||
// recording the credential as a requirement using the name+type carried
|
||||
// in the workflow JSON.
|
||||
const stream = await service.exportWorkflows({
|
||||
const stream = await service.exportPackage({
|
||||
user: sharee,
|
||||
workflowIds: [workflow.id],
|
||||
});
|
||||
@@ -249,7 +249,7 @@ describe('workflow package export — with credentials', () => {
|
||||
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
|
||||
|
||||
try {
|
||||
await service.exportWorkflows({ user: owner, workflowIds: [wfA.id, wfB.id, wfC.id] });
|
||||
await service.exportPackage({ user: owner, workflowIds: [wfA.id, wfB.id, wfC.id] });
|
||||
|
||||
const exportedEvents = emitSpy.mock.calls.filter(
|
||||
([name]) => name === 'n8n-package-exported',
|
||||
@@ -281,7 +281,7 @@ describe('workflow package export — with credentials', () => {
|
||||
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
|
||||
|
||||
try {
|
||||
await service.exportWorkflows({ user: owner, workflowIds: [workflow.id] });
|
||||
await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
|
||||
const exportedEvents = emitSpy.mock.calls.filter(
|
||||
([name]) => name === 'n8n-package-exported',
|
||||
@@ -303,7 +303,7 @@ describe('workflow package export — with credentials', () => {
|
||||
|
||||
try {
|
||||
await expect(
|
||||
service.exportWorkflows({ user: owner, workflowIds: ['does-not-exist'] }),
|
||||
service.exportPackage({ user: owner, workflowIds: ['does-not-exist'] }),
|
||||
).rejects.toThrow(/not found or not accessible/i);
|
||||
|
||||
const exportedEvents = emitSpy.mock.calls.filter(
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import { ControllerRegistryMetadata } from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { Response } from 'express';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { N8nPackagesController } from '../n8n-packages.controller';
|
||||
import type { N8nPackagesService } from '../n8n-packages.service';
|
||||
|
||||
describe('n8n-packages export', () => {
|
||||
describe('exportWorkflows', () => {
|
||||
it('sets gzip Content-Type and .n8np attachment Content-Disposition on the response', async () => {
|
||||
const service = mock<N8nPackagesService>();
|
||||
service.exportWorkflows.mockResolvedValue(new PassThrough());
|
||||
|
||||
const controller = new N8nPackagesController(service);
|
||||
const req = { user: { id: 'user-1' } } as unknown as AuthenticatedRequest;
|
||||
const res = mock<Response>();
|
||||
|
||||
await controller.exportWorkflows(req, res, { workflowIds: ['wf-1'] });
|
||||
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/gzip');
|
||||
expect(res.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="export.n8np"',
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards the authenticated user and workflow ids to the service', async () => {
|
||||
const service = mock<N8nPackagesService>();
|
||||
service.exportWorkflows.mockResolvedValue(new PassThrough());
|
||||
|
||||
const controller = new N8nPackagesController(service);
|
||||
const user = { id: 'user-1' };
|
||||
const req = { user } as unknown as AuthenticatedRequest;
|
||||
|
||||
await controller.exportWorkflows(req, mock<Response>(), { workflowIds: ['wf-a', 'wf-b'] });
|
||||
|
||||
expect(service.exportWorkflows).toHaveBeenCalledWith({
|
||||
user,
|
||||
workflowIds: ['wf-a', 'wf-b'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('route decorators', () => {
|
||||
const route = Container.get(ControllerRegistryMetadata)
|
||||
.getControllerMetadata(N8nPackagesController as never)
|
||||
.routes.get('exportWorkflows');
|
||||
|
||||
it('has no @ProjectScope or @GlobalScope decorator', () => {
|
||||
expect(route?.accessScope).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+10
-10
@@ -37,7 +37,7 @@ describe('workflow package export', () => {
|
||||
});
|
||||
|
||||
async function exportSingleWorkflow(user: User, workflowId: string) {
|
||||
const stream = await service.exportWorkflows({ user, workflowIds: [workflowId] });
|
||||
const stream = await service.exportPackage({ user, workflowIds: [workflowId] });
|
||||
return await readExport(stream);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('workflow package export', () => {
|
||||
const wfA = await createWorkflow({ name: 'Alpha', nodes: [], connections: {} }, project);
|
||||
const wfB = await createWorkflow({ name: 'Beta', nodes: [], connections: {} }, project);
|
||||
|
||||
const stream = await service.exportWorkflows({
|
||||
const stream = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [wfA.id, wfB.id],
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe('workflow package export', () => {
|
||||
const wfA = await createWorkflow({ name: 'Duplicate', nodes: [], connections: {} }, project);
|
||||
const wfB = await createWorkflow({ name: 'Duplicate', nodes: [], connections: {} }, project);
|
||||
|
||||
const stream = await service.exportWorkflows({
|
||||
const stream = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [wfA.id, wfB.id],
|
||||
});
|
||||
@@ -140,7 +140,7 @@ describe('workflow package export', () => {
|
||||
const wf = await createWorkflow({ name: 'Alpha', nodes: [], connections: {} }, project);
|
||||
|
||||
await expect(
|
||||
service.exportWorkflows({
|
||||
service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [wf.id, 'missing-1', 'missing-2'],
|
||||
}),
|
||||
@@ -159,7 +159,7 @@ describe('workflow package export', () => {
|
||||
const outsider = await createMember();
|
||||
|
||||
await expect(
|
||||
service.exportWorkflows({ user: outsider, workflowIds: [ownerWorkflow.id] }),
|
||||
service.exportPackage({ user: outsider, workflowIds: [ownerWorkflow.id] }),
|
||||
).rejects.toThrow('1 workflow(s) not found or not accessible. Export aborted.');
|
||||
});
|
||||
|
||||
@@ -180,7 +180,7 @@ describe('workflow package export', () => {
|
||||
);
|
||||
|
||||
const error = (await service
|
||||
.exportWorkflows({
|
||||
.exportPackage({
|
||||
user: member,
|
||||
workflowIds: [memberWorkflow.id, ownerWorkflow.id],
|
||||
})
|
||||
@@ -201,9 +201,9 @@ describe('workflow package export', () => {
|
||||
memberAPersonal,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.exportWorkflows({ user: memberB, workflowIds: [wf.id] }),
|
||||
).rejects.toThrow('1 workflow(s) not found or not accessible. Export aborted.');
|
||||
await expect(service.exportPackage({ user: memberB, workflowIds: [wf.id] })).rejects.toThrow(
|
||||
'1 workflow(s) not found or not accessible. Export aborted.',
|
||||
);
|
||||
});
|
||||
|
||||
it('denies a member access to a workflow shared only with someone else', async () => {
|
||||
@@ -218,7 +218,7 @@ describe('workflow package export', () => {
|
||||
await shareWorkflowWithUsers(wf, [sharee]);
|
||||
|
||||
await expect(
|
||||
service.exportWorkflows({ user: bystander, workflowIds: [wf.id] }),
|
||||
service.exportPackage({ user: bystander, workflowIds: [wf.id] }),
|
||||
).rejects.toThrow('1 workflow(s) not found or not accessible. Export aborted.');
|
||||
});
|
||||
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import type { Project, User } from '@n8n/db';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { PackageWriter } from '../../../io/package-writer';
|
||||
import { ProjectExporter } from '../project.exporter';
|
||||
import { ProjectSerializer } from '../project.serializer';
|
||||
|
||||
import type { ProjectService } from '@/services/project.service.ee';
|
||||
|
||||
const user = mock<User>({ id: 'user-1' });
|
||||
|
||||
function makeProject(overrides: Partial<Project> = {}): Project {
|
||||
return {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'billing',
|
||||
type: 'team',
|
||||
description: null,
|
||||
icon: null,
|
||||
createdAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||
...overrides,
|
||||
} as Project;
|
||||
}
|
||||
|
||||
class CapturingWriter implements PackageWriter {
|
||||
readonly files: Array<{ path: string; content: string }> = [];
|
||||
|
||||
readonly directories: string[] = [];
|
||||
|
||||
writeFile(path: string, content: string | Buffer): void {
|
||||
this.files.push({ path, content: content.toString() });
|
||||
}
|
||||
|
||||
writeDirectory(path: string): void {
|
||||
this.directories.push(path);
|
||||
}
|
||||
|
||||
finalize(): Readable {
|
||||
throw new Error('not used in this test');
|
||||
}
|
||||
}
|
||||
|
||||
function makeExporter({
|
||||
projects = [],
|
||||
}: {
|
||||
projects?: Project[];
|
||||
} = {}) {
|
||||
const projectService = mock<ProjectService>();
|
||||
|
||||
projectService.findProjectsByIdsForUser.mockImplementation(async (_user, projectIds) => {
|
||||
const accessibleProjects = projects.filter((project) => projectIds.includes(project.id));
|
||||
return [...accessibleProjects].sort((left, right) => {
|
||||
const createdAtDiff = left.createdAt.getTime() - right.createdAt.getTime();
|
||||
return createdAtDiff !== 0 ? createdAtDiff : left.id.localeCompare(right.id);
|
||||
});
|
||||
});
|
||||
|
||||
const exporter = new ProjectExporter(projectService, new ProjectSerializer());
|
||||
return { exporter, projectService };
|
||||
}
|
||||
|
||||
describe('ProjectExporter', () => {
|
||||
it('checks project:export scope for the requested projects', async () => {
|
||||
const project = makeProject();
|
||||
const { exporter, projectService } = makeExporter({ projects: [project] });
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await exporter.export({ user, projectIds: [project.id], writer });
|
||||
|
||||
expect(projectService.findProjectsByIdsForUser).toHaveBeenCalledWith(
|
||||
user,
|
||||
[project.id],
|
||||
['project:export'],
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the user lacks access to a requested project', async () => {
|
||||
const project = makeProject();
|
||||
const { exporter } = makeExporter({ projects: [] });
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await expect(exporter.export({ user, projectIds: [project.id], writer })).rejects.toThrow(
|
||||
'1 project(s) not found or not accessible. Export aborted.',
|
||||
);
|
||||
});
|
||||
|
||||
it('exports an empty team project with project.json only', async () => {
|
||||
const project = makeProject();
|
||||
const { exporter } = makeExporter({ projects: [project] });
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
const { entries } = await exporter.export({ user, projectIds: [project.id], writer });
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
target: 'projects/billing',
|
||||
},
|
||||
]);
|
||||
expect(writer.directories).toEqual(['projects/billing']);
|
||||
expect(writer.files).toEqual([
|
||||
{
|
||||
path: 'projects/billing/project.json',
|
||||
content: expect.stringContaining('"name": "billing"'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('suffixes duplicate project names and sorts by createdAt for stable targets', async () => {
|
||||
const olderProject = makeProject({
|
||||
id: 'project-older',
|
||||
name: 'Billing',
|
||||
createdAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||
});
|
||||
const newerProject = makeProject({
|
||||
id: 'project-newer',
|
||||
name: 'Billing',
|
||||
createdAt: new Date('2024-02-01T00:00:00.000Z'),
|
||||
});
|
||||
const { exporter } = makeExporter({
|
||||
projects: [newerProject, olderProject],
|
||||
});
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
const { entries } = await exporter.export({
|
||||
user,
|
||||
projectIds: [newerProject.id, olderProject.id],
|
||||
writer,
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: olderProject.id,
|
||||
name: olderProject.name,
|
||||
target: 'projects/billing',
|
||||
},
|
||||
{
|
||||
id: newerProject.id,
|
||||
name: newerProject.name,
|
||||
target: 'projects/billing-2',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('exports a personal project', async () => {
|
||||
const project = makeProject({ id: 'personal-1', name: 'Personal', type: 'personal' });
|
||||
const { exporter } = makeExporter({ projects: [project] });
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
const { entries } = await exporter.export({ user, projectIds: [project.id], writer });
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
target: 'projects/personal',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import type { Project } from '@n8n/db';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { ProjectSerializer } from '../project.serializer';
|
||||
|
||||
describe('ProjectSerializer', () => {
|
||||
const serializer = new ProjectSerializer();
|
||||
|
||||
const baseProject = mock<Project>({
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'billing',
|
||||
type: 'team',
|
||||
description: null,
|
||||
icon: null,
|
||||
});
|
||||
|
||||
it('should serialize a project with only id and name when no optional fields', () => {
|
||||
const result = serializer.serialize(baseProject);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'billing',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include description when non-null', () => {
|
||||
const project = { ...baseProject, description: 'Billing project' };
|
||||
const result = serializer.serialize(project as Project);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'billing',
|
||||
description: 'Billing project',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include icon when non-null', () => {
|
||||
const project = { ...baseProject, icon: { type: 'emoji' as const, value: 'receipt' } };
|
||||
const result = serializer.serialize(project as Project);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: 'billing',
|
||||
icon: { type: 'emoji', value: 'receipt' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should omit timestamps and relations', () => {
|
||||
const result = serializer.serialize(baseProject);
|
||||
|
||||
expect(result).not.toHaveProperty('createdAt');
|
||||
expect(result).not.toHaveProperty('updatedAt');
|
||||
expect(result).not.toHaveProperty('type');
|
||||
expect(result).not.toHaveProperty('creatorId');
|
||||
expect(result).not.toHaveProperty('projectRelations');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { ProjectSerializer } from './project.serializer';
|
||||
import type { PackageWriter } from '../../io/package-writer';
|
||||
import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator';
|
||||
import type { ManifestEntry } from '../../spec/manifest.schema';
|
||||
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
|
||||
export interface ProjectExportRequest {
|
||||
user: User;
|
||||
projectIds: string[];
|
||||
writer: PackageWriter;
|
||||
}
|
||||
|
||||
export interface ProjectExportResult {
|
||||
entries: ManifestEntry[];
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class ProjectExporter {
|
||||
constructor(
|
||||
private readonly projectService: ProjectService,
|
||||
private readonly projectSerializer: ProjectSerializer,
|
||||
) {}
|
||||
|
||||
async export(request: ProjectExportRequest): Promise<ProjectExportResult> {
|
||||
const projects = await this.projectService.findProjectsByIdsForUser(
|
||||
request.user,
|
||||
request.projectIds,
|
||||
['project:export'],
|
||||
);
|
||||
|
||||
this.assertAllRequestedProjectsFound(request.projectIds, projects);
|
||||
|
||||
const entries: ManifestEntry[] = [];
|
||||
const targets = new UniqueFilenameAllocator('projects', 'project');
|
||||
|
||||
for (const project of projects) {
|
||||
const target = targets.allocate(project.name);
|
||||
const serialized = this.projectSerializer.serialize(project);
|
||||
|
||||
request.writer.writeDirectory(target);
|
||||
request.writer.writeFile(`${target}/project.json`, JSON.stringify(serialized, null, '\t'));
|
||||
|
||||
entries.push({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
return { entries };
|
||||
}
|
||||
|
||||
private assertAllRequestedProjectsFound(
|
||||
requestedProjectIds: string[],
|
||||
foundProjects: Array<{ id: string }>,
|
||||
) {
|
||||
const foundProjectIds = new Set(foundProjects.map(({ id }) => id));
|
||||
const missingProjectIds = requestedProjectIds.filter((id) => !foundProjectIds.has(id));
|
||||
|
||||
if (missingProjectIds.length > 0) {
|
||||
const displayedProjectIds = missingProjectIds.slice(0, 20);
|
||||
const omittedCount = missingProjectIds.length - displayedProjectIds.length;
|
||||
|
||||
throw new UserError(
|
||||
`${missingProjectIds.length} project(s) not found or not accessible. Export aborted.`,
|
||||
{
|
||||
description: `Missing project IDs: ${displayedProjectIds.join(', ')}${
|
||||
omittedCount > 0 ? `, and ${omittedCount} more` : ''
|
||||
}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Project } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import {
|
||||
serializedProjectSchema,
|
||||
type SerializedProject,
|
||||
} from '../../spec/serialized/project.schema';
|
||||
|
||||
@Service()
|
||||
export class ProjectSerializer {
|
||||
serialize(project: Project): SerializedProject {
|
||||
return serializedProjectSchema.parse({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
...(project.description !== null ? { description: project.description } : {}),
|
||||
...(project.icon !== null ? { icon: project.icon } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
import { WorkflowSerializer } from './workflow.serializer';
|
||||
import type { PackageWriter } from '../../io/package-writer';
|
||||
import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator';
|
||||
@@ -11,6 +9,8 @@ import type { ManifestEntry } from '../../spec/manifest.schema';
|
||||
import { CredentialRequirementsExtractor } from '../credential/credential-requirements.extractor';
|
||||
import type { WorkflowCredentialRequirement } from '../credential/credential.types';
|
||||
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
export interface WorkflowExportRequest {
|
||||
user: User;
|
||||
workflowIds: string[];
|
||||
@@ -46,7 +46,7 @@ export class WorkflowExporter {
|
||||
|
||||
const entries: ManifestEntry[] = [];
|
||||
const credentials: WorkflowCredentialRequirement[] = [];
|
||||
const fileNames = new UniqueFilenameAllocator('workflows');
|
||||
const fileNames = new UniqueFilenameAllocator('workflows', 'workflow');
|
||||
|
||||
for (const workflow of workflows) {
|
||||
const target = fileNames.allocate(workflow.name);
|
||||
|
||||
@@ -2,27 +2,23 @@ import { generateSlug } from '../slug.utils';
|
||||
|
||||
describe('generateSlug', () => {
|
||||
it('lower-cases and hyphenates the name', () => {
|
||||
expect(generateSlug('My Workflow')).toBe('my-workflow');
|
||||
expect(generateSlug('My Workflow', 'workflow')).toBe('my-workflow');
|
||||
});
|
||||
|
||||
it('collapses whitespace and strips disallowed characters', () => {
|
||||
expect(generateSlug(' Hello, World! ')).toBe('hello-world');
|
||||
expect(generateSlug(' Hello, World! ', 'workflow')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('removes emojis', () => {
|
||||
expect(generateSlug('💳 Payments')).toBe('payments');
|
||||
expect(generateSlug('💳 Payments', 'workflow')).toBe('payments');
|
||||
});
|
||||
|
||||
it('falls back to "workflow" when the name yields an empty slug', () => {
|
||||
expect(generateSlug('--')).toBe('workflow');
|
||||
});
|
||||
|
||||
it('uses a caller-provided fallback when the name yields an empty slug', () => {
|
||||
expect(generateSlug('--', 'credential')).toBe('credential');
|
||||
it('uses the fallback when the name yields an empty slug', () => {
|
||||
expect(generateSlug('--', 'workflow')).toBe('workflow');
|
||||
expect(generateSlug('', 'credential')).toBe('credential');
|
||||
});
|
||||
|
||||
it('strips leading and trailing hyphens', () => {
|
||||
expect(generateSlug('---foo---')).toBe('foo');
|
||||
expect(generateSlug('---foo---', 'workflow')).toBe('foo');
|
||||
});
|
||||
});
|
||||
|
||||
+5
-5
@@ -2,20 +2,20 @@ import { UniqueFilenameAllocator } from '../unique-filename-allocator';
|
||||
|
||||
describe('UniqueFilenameAllocator', () => {
|
||||
it('returns the slugged base path on first allocation', () => {
|
||||
const allocator = new UniqueFilenameAllocator('workflows');
|
||||
const allocator = new UniqueFilenameAllocator('workflows', 'workflow');
|
||||
|
||||
expect(allocator.allocate('My Workflow')).toBe('workflows/my-workflow');
|
||||
});
|
||||
|
||||
it('suffixes -2 when a slug collides with an earlier allocation', () => {
|
||||
const allocator = new UniqueFilenameAllocator('workflows');
|
||||
const allocator = new UniqueFilenameAllocator('workflows', 'workflow');
|
||||
|
||||
allocator.allocate('Same Name');
|
||||
expect(allocator.allocate('Same Name')).toBe('workflows/same-name-2');
|
||||
});
|
||||
|
||||
it('keeps incrementing the suffix until it finds a free slot', () => {
|
||||
const allocator = new UniqueFilenameAllocator('workflows');
|
||||
const allocator = new UniqueFilenameAllocator('workflows', 'workflow');
|
||||
|
||||
allocator.allocate('Same Name');
|
||||
allocator.allocate('Same Name');
|
||||
@@ -39,8 +39,8 @@ describe('UniqueFilenameAllocator', () => {
|
||||
});
|
||||
|
||||
it('keeps the per-instance used set independent across allocators', () => {
|
||||
const a = new UniqueFilenameAllocator('workflows');
|
||||
const b = new UniqueFilenameAllocator('workflows');
|
||||
const a = new UniqueFilenameAllocator('workflows', 'workflow');
|
||||
const b = new UniqueFilenameAllocator('workflows', 'workflow');
|
||||
|
||||
expect(a.allocate('Same Name')).toBe('workflows/same-name');
|
||||
expect(b.allocate('Same Name')).toBe('workflows/same-name');
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
const DEFAULT_EMPTY_SLUG_FALLBACK = 'workflow';
|
||||
|
||||
/**
|
||||
* Generates a filesystem-safe slug from an entity name
|
||||
*/
|
||||
export function generateSlug(name: string, fallback: string = DEFAULT_EMPTY_SLUG_FALLBACK): string {
|
||||
export function generateSlug(name: string, fallback: string): string {
|
||||
let slug = name;
|
||||
slug = slug
|
||||
.toLowerCase()
|
||||
|
||||
@@ -5,7 +5,7 @@ export class UniqueFilenameAllocator {
|
||||
|
||||
constructor(
|
||||
private readonly baseDir: string,
|
||||
private readonly fallback?: string,
|
||||
private readonly fallback: string,
|
||||
) {}
|
||||
|
||||
allocate(name: string): string {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { ExportWorkflowsRequestDto } from '@n8n/api-types';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Body, Post, RestController } from '@n8n/decorators';
|
||||
import type { Response } from 'express';
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
import { N8nPackagesService } from './n8n-packages.service';
|
||||
|
||||
@RestController('/n8n-packages')
|
||||
export class N8nPackagesController {
|
||||
constructor(private readonly packagesService: N8nPackagesService) {}
|
||||
|
||||
@Post('/export')
|
||||
async exportWorkflows(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
@Body body: ExportWorkflowsRequestDto,
|
||||
): Promise<Readable> {
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="export.n8np"');
|
||||
|
||||
return await this.packagesService.exportWorkflows({
|
||||
user: req.user,
|
||||
workflowIds: body.workflowIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,6 @@ import { BackendModule } from '@n8n/decorators';
|
||||
})
|
||||
export class N8nPackagesModule implements ModuleInterface {
|
||||
async init() {
|
||||
await import('./n8n-packages.controller');
|
||||
await import('./n8n-packages.service');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@ import { EventService } from '@/events/event.service';
|
||||
|
||||
import { ImportPipeline } from './engine/import-pipeline';
|
||||
import { CredentialExporter } from './entities/credential/credential.exporter';
|
||||
import { ProjectExporter } from './entities/project/project.exporter';
|
||||
import { WorkflowExporter } from './entities/workflow/workflow.exporter';
|
||||
import { TarPackageWriter } from './io/tar/tar-package-writer';
|
||||
import type {
|
||||
ExportWorkflowsRequest,
|
||||
ExportPackageRequest,
|
||||
ImportPackageRequest,
|
||||
ImportResult,
|
||||
} from './n8n-packages.types';
|
||||
@@ -20,6 +21,7 @@ import { packageManifestSchema } from './spec/manifest.schema';
|
||||
@Service()
|
||||
export class N8nPackagesService {
|
||||
constructor(
|
||||
private readonly projectExporter: ProjectExporter,
|
||||
private readonly workflowExporter: WorkflowExporter,
|
||||
private readonly credentialExporter: CredentialExporter,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
@@ -27,33 +29,48 @@ export class N8nPackagesService {
|
||||
private readonly eventService: EventService,
|
||||
) {}
|
||||
|
||||
async exportWorkflows(request: ExportWorkflowsRequest): Promise<Readable> {
|
||||
async exportPackage(request: ExportPackageRequest): Promise<Readable> {
|
||||
const writer = new TarPackageWriter();
|
||||
const workflowIds = request.workflowIds ?? [];
|
||||
const projectIds = request.projectIds ?? [];
|
||||
|
||||
const { entries: workflowEntries, requirements: workflowRequirements } =
|
||||
await this.workflowExporter.export({
|
||||
user: request.user,
|
||||
workflowIds: request.workflowIds,
|
||||
writer,
|
||||
});
|
||||
const workflowExportResult =
|
||||
workflowIds.length > 0
|
||||
? await this.workflowExporter.export({
|
||||
user: request.user,
|
||||
workflowIds,
|
||||
writer,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const { entries: credentialEntries, requirements: credentialRequirements } =
|
||||
await this.credentialExporter.export({
|
||||
user: request.user,
|
||||
requirements: workflowRequirements.credentials,
|
||||
writer,
|
||||
});
|
||||
const projectExportResult =
|
||||
projectIds.length > 0
|
||||
? await this.projectExporter.export({
|
||||
user: request.user,
|
||||
projectIds,
|
||||
writer,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const credentialExportResult = await this.credentialExporter.export({
|
||||
user: request.user,
|
||||
requirements: workflowExportResult?.requirements?.credentials ?? [],
|
||||
writer,
|
||||
});
|
||||
|
||||
const manifest = packageManifestSchema.parse({
|
||||
packageFormatVersion: FORMAT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
sourceN8nVersion: N8N_VERSION,
|
||||
sourceId: this.instanceSettings.instanceId,
|
||||
workflows: workflowEntries,
|
||||
...(credentialEntries.length > 0 ? { credentials: credentialEntries } : {}),
|
||||
...(credentialRequirements.length > 0
|
||||
? { requirements: { credentials: credentialRequirements } }
|
||||
...(credentialExportResult.entries.length > 0
|
||||
? { credentials: credentialExportResult.entries }
|
||||
: {}),
|
||||
...(credentialExportResult.requirements.length > 0
|
||||
? { requirements: { credentials: credentialExportResult.requirements } }
|
||||
: {}),
|
||||
...(workflowExportResult?.entries ? { workflows: workflowExportResult.entries } : {}),
|
||||
...(projectExportResult?.entries ? { projects: projectExportResult.entries } : {}),
|
||||
});
|
||||
|
||||
writer.writeFile('manifest.json', JSON.stringify(manifest, null, '\t'));
|
||||
@@ -64,8 +81,8 @@ export class N8nPackagesService {
|
||||
this.eventService.emit('n8n-package-exported', {
|
||||
user: request.user,
|
||||
counts: {
|
||||
workflows: workflowEntries.length,
|
||||
credentials: credentialEntries.length,
|
||||
workflows: workflowExportResult?.entries.length ?? 0,
|
||||
credentials: credentialExportResult.entries.length,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -35,9 +35,10 @@ export type WorkflowConflictPolicy =
|
||||
|
||||
export type WorkflowIdPolicy = (typeof WorkflowIdPolicy)[keyof typeof WorkflowIdPolicy];
|
||||
|
||||
export interface ExportWorkflowsRequest {
|
||||
export interface ExportPackageRequest {
|
||||
user: User;
|
||||
workflowIds: string[];
|
||||
workflowIds?: string[];
|
||||
projectIds?: string[];
|
||||
}
|
||||
|
||||
export type ImportPackageRequest = {
|
||||
|
||||
@@ -9,6 +9,26 @@ export const manifestEntrySchema = z.object({
|
||||
target: z.string().min(1),
|
||||
});
|
||||
|
||||
type ManifestEntryInput = z.infer<typeof manifestEntrySchema>;
|
||||
|
||||
function assertNoDuplicateIds(
|
||||
items: Array<Pick<ManifestEntryInput, 'id'>> | undefined,
|
||||
ctx: z.RefinementCtx,
|
||||
): void {
|
||||
if (!items) return;
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
for (const item of items) {
|
||||
if (seenIds.has(item.id)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate id in manifest: ${item.id}`,
|
||||
});
|
||||
}
|
||||
seenIds.add(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
export const packageManifestSchema = z
|
||||
.object({
|
||||
packageFormatVersion: z.literal(FORMAT_VERSION),
|
||||
@@ -16,21 +36,14 @@ export const packageManifestSchema = z
|
||||
sourceN8nVersion: z.string().min(1),
|
||||
sourceId: z.string().min(1),
|
||||
workflows: z.array(manifestEntrySchema).optional(),
|
||||
projects: z.array(manifestEntrySchema).optional(),
|
||||
credentials: z.array(manifestEntrySchema).optional(),
|
||||
requirements: packageRequirementsSchema.optional(),
|
||||
})
|
||||
.superRefine((manifest, ctx) => {
|
||||
if (!manifest.workflows) return;
|
||||
const seen = new Set<string>();
|
||||
for (const entry of manifest.workflows) {
|
||||
if (seen.has(entry.id)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate workflow id in manifest: ${entry.id}`,
|
||||
});
|
||||
}
|
||||
seen.add(entry.id);
|
||||
}
|
||||
assertNoDuplicateIds(manifest.workflows, ctx);
|
||||
assertNoDuplicateIds(manifest.projects, ctx);
|
||||
assertNoDuplicateIds(manifest.credentials, ctx);
|
||||
});
|
||||
|
||||
export type ManifestEntry = z.infer<typeof manifestEntrySchema>;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const projectIconSchema = z.object({
|
||||
type: z.enum(['emoji', 'icon']),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const serializedProjectSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
icon: projectIconSchema.optional(),
|
||||
});
|
||||
|
||||
export type SerializedProject = z.infer<typeof serializedProjectSchema>;
|
||||
@@ -10,11 +10,6 @@ import { hasGlobalScope } from '@n8n/permissions';
|
||||
import * as express from 'express';
|
||||
import type { PullResult } from 'simple-git';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { IWorkflowToImport } from '@/interfaces';
|
||||
|
||||
import { SOURCE_CONTROL_DEFAULT_BRANCH } from './constants';
|
||||
import { sourceControlEnabledMiddleware } from './middleware/source-control-enabled-middleware.ee';
|
||||
import { SourceControlContextFactory } from './source-control-context.factory';
|
||||
@@ -27,6 +22,11 @@ import { SourceControlRequest } from './types/requests';
|
||||
import { SourceControlGetStatus } from './types/source-control-get-status';
|
||||
import type { SourceControlPreferences } from './types/source-control-preferences';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
import { IWorkflowToImport } from '@/interfaces';
|
||||
|
||||
@RestController('/source-control')
|
||||
export class SourceControlController {
|
||||
constructor(
|
||||
|
||||
@@ -99,7 +99,10 @@ describe('Public API scope parity', () => {
|
||||
|
||||
test('every API key scope in API_KEY_RESOURCES is consumed by at least one endpoint', () => {
|
||||
const consumed = new Set(
|
||||
ops.map((op) => op.requiredScope).filter((s): s is string => s !== null && s !== 'none'),
|
||||
ops.flatMap((op) => {
|
||||
if (op.requiredScope === null || op.requiredScope === 'none') return [];
|
||||
return op.requiredScope.split(',').map((scope) => scope.trim());
|
||||
}),
|
||||
);
|
||||
const declared = new Set<string>();
|
||||
for (const [resource, operations] of Object.entries(API_KEY_RESOURCES)) {
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { Response } from 'express';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import type { Mocked } from 'vitest';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service';
|
||||
import * as middlewares from '@/public-api/v1/shared/middlewares/global.middleware';
|
||||
|
||||
const mockMiddleware = vi.fn(async (_req: unknown, _res: unknown, next: unknown) =>
|
||||
(next as () => void)(),
|
||||
) as unknown as middlewares.ScopeTaggedMiddleware;
|
||||
vi.spyOn(middlewares, 'publicApiCompositeScope').mockReturnValue(mockMiddleware);
|
||||
vi.spyOn(middlewares, 'publicApiScope').mockReturnValue(mockMiddleware);
|
||||
|
||||
// Loaded after the middleware spies above are installed (the handler captures
|
||||
// middleware at module-evaluation time). Typed loosely to invoke route entries.
|
||||
let handler: Record<string, Array<(...args: unknown[]) => unknown>>;
|
||||
// exportPackage = [publicApiCompositeScope(...), businessLogic]; index 1 is the handler under test.
|
||||
let exportPackage: (...args: unknown[]) => unknown;
|
||||
|
||||
beforeAll(async () => {
|
||||
handler = (await import('../n8n-packages.handler')) as unknown as typeof handler;
|
||||
exportPackage = handler.exportPackage[1];
|
||||
});
|
||||
|
||||
describe('n8n-packages handler', () => {
|
||||
let packagesEnabled: boolean;
|
||||
let mockService: Mocked<N8nPackagesService>;
|
||||
|
||||
function makeRequest(
|
||||
body: { workflowIds?: string[]; projectIds?: string[] },
|
||||
apiKeyScopes?: string[],
|
||||
) {
|
||||
return {
|
||||
user: { id: 'user-1' },
|
||||
body,
|
||||
tokenGrant: apiKeyScopes ? { apiKeyScopes } : undefined,
|
||||
} as unknown as AuthenticatedRequest;
|
||||
}
|
||||
|
||||
function makeResponse() {
|
||||
const res = new PassThrough() as unknown as Response & PassThrough;
|
||||
res.setHeader = vi.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
async function run(req: AuthenticatedRequest, res: Response) {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await exportPackage(req, res);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
return caught;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
packagesEnabled = true;
|
||||
mockService = mockInstance(N8nPackagesService);
|
||||
|
||||
vi.spyOn(Container, 'get').mockImplementation((serviceClass) => {
|
||||
if (serviceClass === GlobalConfig) {
|
||||
return { publicApi: { packagesEnabled } } as never;
|
||||
}
|
||||
if (
|
||||
serviceClass === N8nPackagesService ||
|
||||
(typeof serviceClass === 'function' && serviceClass.name === 'N8nPackagesService')
|
||||
) {
|
||||
return mockService;
|
||||
}
|
||||
return {} as never;
|
||||
});
|
||||
});
|
||||
|
||||
it('throws NotFoundError when packages are disabled', async () => {
|
||||
packagesEnabled = false;
|
||||
|
||||
const caught = await run(
|
||||
makeRequest({ workflowIds: ['wf-1'] }, ['workflow:export']),
|
||||
makeResponse(),
|
||||
);
|
||||
|
||||
expect(caught).toBeInstanceOf(NotFoundError);
|
||||
expect(mockService.exportPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws BadRequestError when the payload fails DTO validation', async () => {
|
||||
const caught = await run(
|
||||
makeRequest({ workflowIds: [''] }, ['workflow:export']),
|
||||
makeResponse(),
|
||||
);
|
||||
|
||||
expect(caught).toBeInstanceOf(BadRequestError);
|
||||
expect(mockService.exportPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws BadRequestError when both workflowIds and projectIds are provided', async () => {
|
||||
const caught = await run(
|
||||
makeRequest({ workflowIds: ['wf-1'], projectIds: ['project-1'] }, [
|
||||
'workflow:export',
|
||||
'project:export',
|
||||
]),
|
||||
makeResponse(),
|
||||
);
|
||||
|
||||
expect(caught).toBeInstanceOf(BadRequestError);
|
||||
expect(caught).toMatchObject({
|
||||
message: 'Provide either workflowIds or projectIds, not both',
|
||||
});
|
||||
expect(mockService.exportPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws BadRequestError when neither workflowIds nor projectIds are provided', async () => {
|
||||
const caught = await run(makeRequest({}, ['workflow:export']), makeResponse());
|
||||
|
||||
expect(caught).toBeInstanceOf(BadRequestError);
|
||||
expect(caught).toMatchObject({
|
||||
message: 'At least one workflowId or projectId is required',
|
||||
});
|
||||
expect(mockService.exportPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ForbiddenError when the API key carries no scopes', async () => {
|
||||
const caught = await run(makeRequest({ workflowIds: ['wf-1'] }), makeResponse());
|
||||
|
||||
expect(caught).toBeInstanceOf(ForbiddenError);
|
||||
expect(mockService.exportPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ForbiddenError when exporting workflows without workflow:export scope', async () => {
|
||||
const caught = await run(
|
||||
makeRequest({ workflowIds: ['wf-1'] }, ['project:export']),
|
||||
makeResponse(),
|
||||
);
|
||||
|
||||
expect(caught).toBeInstanceOf(ForbiddenError);
|
||||
expect(mockService.exportPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ForbiddenError when exporting projects without project:export scope', async () => {
|
||||
const caught = await run(
|
||||
makeRequest({ projectIds: ['project-1'] }, ['workflow:export']),
|
||||
makeResponse(),
|
||||
);
|
||||
|
||||
expect(caught).toBeInstanceOf(ForbiddenError);
|
||||
expect(mockService.exportPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('streams the export for a valid workflow request', async () => {
|
||||
const stream = new PassThrough();
|
||||
mockService.exportPackage.mockResolvedValue(stream);
|
||||
const res = makeResponse();
|
||||
|
||||
const resultPromise = run(
|
||||
makeRequest({ workflowIds: ['wf-1', 'wf-2'] }, ['workflow:export']),
|
||||
res,
|
||||
);
|
||||
stream.end(Buffer.from('package-bytes'));
|
||||
const caught = await resultPromise;
|
||||
|
||||
expect(caught).toBeUndefined();
|
||||
expect(mockService.exportPackage).toHaveBeenCalledWith({
|
||||
user: { id: 'user-1' },
|
||||
workflowIds: ['wf-1', 'wf-2'],
|
||||
projectIds: [],
|
||||
});
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/gzip');
|
||||
expect(res.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="export.n8np"',
|
||||
);
|
||||
});
|
||||
|
||||
it('streams the export for a valid project request', async () => {
|
||||
const stream = new PassThrough();
|
||||
mockService.exportPackage.mockResolvedValue(stream);
|
||||
const res = makeResponse();
|
||||
|
||||
const resultPromise = run(makeRequest({ projectIds: ['project-1'] }, ['project:export']), res);
|
||||
stream.end(Buffer.from('package-bytes'));
|
||||
const caught = await resultPromise;
|
||||
|
||||
expect(caught).toBeUndefined();
|
||||
expect(mockService.exportPackage).toHaveBeenCalledWith({
|
||||
user: { id: 'user-1' },
|
||||
workflowIds: [],
|
||||
projectIds: ['project-1'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,59 +1,113 @@
|
||||
import { ExportWorkflowsRequestDto, ImportPackageRequestDto } from '@n8n/api-types';
|
||||
import { ExportPackageRequestDto, ImportPackageRequestDto } from '@n8n/api-types';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { ApiKeyScope } from '@n8n/permissions';
|
||||
import type { Response } from 'express';
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
import type { PackageRequest } from '../../../types';
|
||||
import type { PublicAPIEndpoint } from '../../shared/handler.types';
|
||||
import {
|
||||
publicApiCompositeScope,
|
||||
publicApiScope,
|
||||
} from '../../shared/middlewares/global.middleware';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service';
|
||||
import { resolveImportPackageUpload } from '@/modules/n8n-packages/utils/import-package-upload';
|
||||
|
||||
import type { PackageRequest } from '../../../types';
|
||||
import type { PublicAPIEndpoint } from '../../shared/handler.types';
|
||||
import { publicApiScope } from '../../shared/middlewares/global.middleware';
|
||||
const PACKAGE_EXPORT_SCOPES = 'project:export,workflow:export';
|
||||
|
||||
type ExportWorkflowsRequest = AuthenticatedRequest<{}, {}, { workflowIds: string[] }>;
|
||||
type ExportPackageRequest = AuthenticatedRequest<
|
||||
{},
|
||||
{},
|
||||
{ workflowIds?: string[]; projectIds?: string[] }
|
||||
>;
|
||||
|
||||
type ImportPackageRequest = PackageRequest.Import & {
|
||||
files?: Express.Multer.File[];
|
||||
};
|
||||
|
||||
type N8nPackagesHandlers = {
|
||||
exportWorkflows: PublicAPIEndpoint<ExportWorkflowsRequest>;
|
||||
exportPackage: PublicAPIEndpoint<ExportPackageRequest>;
|
||||
importPackage: PublicAPIEndpoint<ImportPackageRequest>;
|
||||
};
|
||||
|
||||
function assertPackageExportApiKeyScopes(
|
||||
req: AuthenticatedRequest,
|
||||
workflowIds: string[],
|
||||
projectIds: string[],
|
||||
) {
|
||||
const apiKeyScopes = req.tokenGrant?.apiKeyScopes;
|
||||
if (!apiKeyScopes) {
|
||||
throw new ForbiddenError('Forbidden');
|
||||
}
|
||||
|
||||
const requiredScopes: ApiKeyScope[] = [];
|
||||
if (workflowIds.length > 0) {
|
||||
requiredScopes.push('workflow:export');
|
||||
}
|
||||
if (projectIds.length > 0) {
|
||||
requiredScopes.push('project:export');
|
||||
}
|
||||
|
||||
for (const scope of requiredScopes) {
|
||||
if (!apiKeyScopes.includes(scope)) {
|
||||
throw new ForbiddenError('Forbidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function streamPackageExport(res: Response, stream: Readable): Promise<Response> {
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="export.n8np"');
|
||||
|
||||
return await new Promise<Response>((resolve, reject) => {
|
||||
stream.on('error', reject);
|
||||
res.on('finish', () => resolve(res));
|
||||
res.on('close', () => {
|
||||
if (!res.writableFinished) stream.destroy();
|
||||
resolve(res);
|
||||
});
|
||||
stream.pipe(res);
|
||||
});
|
||||
}
|
||||
|
||||
const n8nPackagesHandlers: N8nPackagesHandlers = {
|
||||
exportWorkflows: [
|
||||
publicApiScope('workflow:export'),
|
||||
exportPackage: [
|
||||
publicApiCompositeScope(PACKAGE_EXPORT_SCOPES),
|
||||
async (req, res) => {
|
||||
if (!Container.get(GlobalConfig).publicApi.packagesEnabled) {
|
||||
throw new NotFoundError('Not Found');
|
||||
}
|
||||
|
||||
const payload = ExportWorkflowsRequestDto.safeParse(req.body);
|
||||
const payload = ExportPackageRequestDto.safeParse(req.body);
|
||||
if (!payload.success) {
|
||||
throw new BadRequestError(payload.error.errors.map(({ message }) => message).join('; '));
|
||||
}
|
||||
if (payload.data.workflowIds && payload.data.projectIds) {
|
||||
throw new BadRequestError('Provide either workflowIds or projectIds, not both');
|
||||
}
|
||||
|
||||
const stream = await Container.get(N8nPackagesService).exportWorkflows({
|
||||
const workflowIds = payload.data.workflowIds ?? [];
|
||||
const projectIds = payload.data.projectIds ?? [];
|
||||
|
||||
if (workflowIds.length === 0 && projectIds.length === 0) {
|
||||
throw new BadRequestError('At least one workflowId or projectId is required');
|
||||
}
|
||||
|
||||
assertPackageExportApiKeyScopes(req, workflowIds, projectIds);
|
||||
|
||||
const stream = await Container.get(N8nPackagesService).exportPackage({
|
||||
user: req.user,
|
||||
workflowIds: payload.data.workflowIds,
|
||||
workflowIds,
|
||||
projectIds,
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="export.n8np"');
|
||||
|
||||
return await new Promise<Response>((resolve, reject) => {
|
||||
stream.on('error', reject);
|
||||
res.on('finish', () => resolve(res));
|
||||
res.on('close', () => {
|
||||
if (!res.writableFinished) stream.destroy();
|
||||
resolve(res);
|
||||
});
|
||||
stream.pipe(res);
|
||||
});
|
||||
return await streamPackageExport(res, stream);
|
||||
},
|
||||
],
|
||||
importPackage: [
|
||||
|
||||
+13
-9
@@ -1,27 +1,31 @@
|
||||
post:
|
||||
x-eov-operation-id: exportWorkflows
|
||||
x-required-scope: workflow:export
|
||||
x-eov-operation-id: exportPackage
|
||||
x-required-scope: project:export,workflow:export
|
||||
x-eov-operation-handler: v1/handlers/n8n-packages/n8n-packages.handler
|
||||
tags:
|
||||
- N8nPackage
|
||||
summary: 'Beta: Export workflows as an n8n package'
|
||||
summary: 'Beta: Export workflows and projects as an n8n package'
|
||||
description: |
|
||||
**Beta — disabled by default.** Set `N8N_PUBLIC_API_PACKAGES_ENABLED=true` on the
|
||||
instance to enable this endpoint. While disabled, requests return `404`.
|
||||
|
||||
Export one or more workflows as a gzipped tar archive (.n8np). The response is
|
||||
streamed as `application/gzip` with a `Content-Disposition` attachment header.
|
||||
Requires the n8n Packages feature to be licensed.
|
||||
Export either workflows or projects as a gzipped tar archive (.n8np).
|
||||
Provide `workflowIds` or `projectIds`, but not both. Empty projects export
|
||||
project metadata only. The response is streamed as `application/gzip` with a
|
||||
`Content-Disposition` attachment header. Requires the n8n Packages feature to be
|
||||
licensed.
|
||||
|
||||
API key scopes: `workflow:export` is required when exporting workflows, and `project:export` is required when exporting projects.
|
||||
requestBody:
|
||||
description: Workflows to include in the exported package.
|
||||
description: Workflows or projects to include in the exported package.
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/exportWorkflowsRequest.yml'
|
||||
$ref: '../schemas/exportPackageRequest.yml'
|
||||
responses:
|
||||
'200':
|
||||
description: A gzipped tar archive containing the exported workflows.
|
||||
description: A gzipped tar archive containing the exported package contents.
|
||||
content:
|
||||
application/gzip:
|
||||
schema:
|
||||
|
||||
+8
-3
@@ -1,11 +1,8 @@
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- workflowIds
|
||||
properties:
|
||||
workflowIds:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 300
|
||||
description: IDs of the workflows to include in the exported package.
|
||||
items:
|
||||
@@ -13,3 +10,11 @@ properties:
|
||||
minLength: 1
|
||||
example:
|
||||
- 2tUt1wbLX592XDdX
|
||||
projectIds:
|
||||
type: array
|
||||
description: IDs of the projects to include in the exported package.
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
example:
|
||||
- Ox8O54VQrmBrb4qL
|
||||
@@ -128,6 +128,20 @@ function makePublicApiScopeEnforcementMiddleware(endpointScope: ApiKeyScope) {
|
||||
export const publicApiScope = (apiKeyScope: ApiKeyScope) =>
|
||||
tagMiddleware(makePublicApiScopeEnforcementMiddleware(apiKeyScope), apiKeyScope);
|
||||
|
||||
/**
|
||||
* Tags an endpoint with a composite `x-required-scope` value (comma-separated).
|
||||
* Actual scope enforcement is deferred to the handler, which picks the required
|
||||
* scope based on the request payload.
|
||||
*/
|
||||
export function publicApiCompositeScope(requiredScopes: string): ScopeTaggedMiddleware {
|
||||
return Object.assign(
|
||||
(_req: Request, _res: Response, next: NextFunction) => {
|
||||
next();
|
||||
},
|
||||
{ __apiKeyScope: requiredScopes as ApiKeyScope },
|
||||
);
|
||||
}
|
||||
|
||||
export const apiKeyHasScopeWithGlobalScopeFallback = (
|
||||
config: { scope: ApiKeyScope & Scope } | { apiKeyScope: ApiKeyScope; globalScope: Scope },
|
||||
) => {
|
||||
|
||||
@@ -713,6 +713,33 @@ export class ProjectService {
|
||||
return projects.map((p) => p.id);
|
||||
}
|
||||
|
||||
async findProjectsByIdsForUser(
|
||||
user: User,
|
||||
projectIds: string[],
|
||||
scopes: Scope[],
|
||||
): Promise<Project[]> {
|
||||
if (projectIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const where: FindOptionsWhere<Project> = {
|
||||
id: In(projectIds),
|
||||
};
|
||||
|
||||
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
|
||||
const projectRoles = await this.roleService.rolesWithScope('project', scopes);
|
||||
where.projectRelations = {
|
||||
role: In(projectRoles),
|
||||
userId: user.id,
|
||||
};
|
||||
}
|
||||
|
||||
return await this.projectRepository.find({
|
||||
where,
|
||||
order: { createdAt: 'ASC', id: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a user to a team project with specified roles.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { testDb } from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
|
||||
import { createOwnerWithApiKey } from '../shared/db/users';
|
||||
import type { SuperAgentTest } from '../shared/types';
|
||||
import * as utils from '../shared/utils/';
|
||||
|
||||
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
|
||||
|
||||
let owner: User;
|
||||
let authOwnerAgent: SuperAgentTest;
|
||||
|
||||
beforeAll(async () => {
|
||||
owner = await createOwnerWithApiKey();
|
||||
Container.get(InstanceSettings).markAsLeader();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
authOwnerAgent = testServer.publicApiAgentFor(owner);
|
||||
Container.get(GlobalConfig).publicApi.packagesEnabled = true;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
Container.get(GlobalConfig).publicApi.packagesEnabled = false;
|
||||
await testDb.truncate(['WorkflowEntity', 'SharedWorkflow', 'ProjectRelation', 'Project']);
|
||||
});
|
||||
|
||||
describe('POST /n8n-packages/export', () => {
|
||||
test('rejects requests that provide both workflowIds and projectIds', async () => {
|
||||
const response = await authOwnerAgent.post('/n8n-packages/export').send({
|
||||
workflowIds: ['wf-1'],
|
||||
projectIds: ['project-1'],
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
message: 'Provide either workflowIds or projectIds, not both',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
} from 'n8n-workflow';
|
||||
import { agent as testAgent } from 'supertest';
|
||||
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { WebhookServer } from '@/webhooks/webhook-server';
|
||||
|
||||
import { createUser } from './shared/db/users';
|
||||
import type { SuperAgentTest } from './shared/types';
|
||||
import { initActiveWorkflowManager } from './shared/utils';
|
||||
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { WebhookServer } from '@/webhooks/webhook-server';
|
||||
|
||||
vi.unmock('node:fs');
|
||||
|
||||
class WebhookTestingNode implements INodeType {
|
||||
|
||||
Reference in New Issue
Block a user