mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-27 11:05:14 +02:00
fix(n8n-cli): Report real exported entity counts in package export
Surface the true per-entity counts computed during export through the public-API response (as an X-N8n-Export-Counts header) so the CLI prints accurate counts instead of the requested id-flag lengths. A workflow-only export no longer appends a spurious "0 folder(s)" and a folder export now reports its bundled workflow count. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1f4e65419a
commit
79de5bdb72
|
|
@ -48,8 +48,10 @@ describe('N8nClient packages', () => {
|
|||
|
||||
const result = await client.exportPackage({ workflowIds: ['a', 'b'] });
|
||||
|
||||
expect(Buffer.isBuffer(result)).toBe(true);
|
||||
expect(result.equals(Buffer.from([1, 2, 3]))).toBe(true);
|
||||
expect(Buffer.isBuffer(result.archive)).toBe(true);
|
||||
expect(result.archive.equals(Buffer.from([1, 2, 3]))).toBe(true);
|
||||
// Older servers omit the counts header.
|
||||
expect(result.counts).toBeUndefined();
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('https://n8n.example.com/api/v1/n8n-packages/export');
|
||||
|
|
@ -61,14 +63,26 @@ describe('N8nClient packages', () => {
|
|||
|
||||
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);
|
||||
expect(Buffer.isBuffer(result.archive)).toBe(true);
|
||||
expect(result.archive.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'] }));
|
||||
});
|
||||
|
||||
it('parses the X-N8n-Export-Counts header into counts when the server sends it', async () => {
|
||||
const counts = { workflows: 2, folders: 1, credentials: 0, dataTables: 0, variables: 0 };
|
||||
const response = binaryResponse(200, new Uint8Array([1, 2, 3]));
|
||||
response.headers.set('X-N8n-Export-Counts', JSON.stringify(counts));
|
||||
fetchMock.mockResolvedValue(response);
|
||||
|
||||
const result = await client.exportPackage({ workflowIds: ['a'] });
|
||||
|
||||
expect(result.counts).toEqual(counts);
|
||||
expect(result.archive.equals(Buffer.from([1, 2, 3]))).toBe(true);
|
||||
});
|
||||
|
||||
it('includes folderIds in the body when provided', async () => {
|
||||
fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1])));
|
||||
|
||||
|
|
|
|||
|
|
@ -22,13 +22,23 @@ interface ExportFlags {
|
|||
interface ExportInternals {
|
||||
parse: () => Promise<{ flags: ExportFlags }>;
|
||||
getClient: () => N8nClient;
|
||||
succeed: () => void;
|
||||
succeed: (message: string, ...args: unknown[]) => void;
|
||||
error: (message: string) => never;
|
||||
}
|
||||
|
||||
const DEFAULT_COUNTS = {
|
||||
workflows: 0,
|
||||
folders: 0,
|
||||
credentials: 0,
|
||||
dataTables: 0,
|
||||
variables: 0,
|
||||
};
|
||||
|
||||
function stubCommand(
|
||||
flags: ExportFlags,
|
||||
exportPackage = vi.fn().mockResolvedValue(Buffer.from([1, 2, 3])),
|
||||
exportPackage = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ archive: Buffer.from([1, 2, 3]), counts: DEFAULT_COUNTS }),
|
||||
) {
|
||||
const command = new PackageExport([], {} as Config);
|
||||
const internals = command as unknown as ExportInternals;
|
||||
|
|
@ -232,4 +242,37 @@ describe('package export command', () => {
|
|||
await expect(command.run()).rejects.toThrow(/at least one/i);
|
||||
expect(exportPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omits a zero folder count from a workflow-only export message', async () => {
|
||||
const exportPackage = vi.fn().mockResolvedValue({
|
||||
archive: Buffer.from([1, 2, 3]),
|
||||
counts: { workflows: 2, folders: 0, credentials: 0, dataTables: 0, variables: 0 },
|
||||
});
|
||||
const { command, internals } = stubCommand(
|
||||
{ workflowId: ['wf-1', 'wf-2'], output: '/tmp/team.n8np' },
|
||||
exportPackage,
|
||||
);
|
||||
|
||||
await command.run();
|
||||
|
||||
const message = vi.mocked(internals.succeed).mock.calls[0][0];
|
||||
expect(message).toBe('Exported 2 workflow(s) to /tmp/team.n8np');
|
||||
expect(message).not.toContain('folder');
|
||||
});
|
||||
|
||||
it('reports the real bundled workflow count for a folder export', async () => {
|
||||
const exportPackage = vi.fn().mockResolvedValue({
|
||||
archive: Buffer.from([1, 2, 3]),
|
||||
counts: { workflows: 3, folders: 1, credentials: 0, dataTables: 0, variables: 0 },
|
||||
});
|
||||
const { command, internals } = stubCommand(
|
||||
{ folderId: ['fld-1'], output: '/tmp/folders.n8np' },
|
||||
exportPackage,
|
||||
);
|
||||
|
||||
await command.run();
|
||||
|
||||
const message = vi.mocked(internals.succeed).mock.calls[0][0];
|
||||
expect(message).toBe('Exported 3 workflow(s), 1 folder(s) to /tmp/folders.n8np');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,6 +34,21 @@ export interface ExportPackageFields {
|
|||
missingWorkflowDependencyPolicy?: string;
|
||||
}
|
||||
|
||||
/** True per-entity counts of what ended up in an exported package. */
|
||||
export interface ExportPackageCounts {
|
||||
workflows: number;
|
||||
folders: number;
|
||||
credentials: number;
|
||||
dataTables: number;
|
||||
variables: number;
|
||||
}
|
||||
|
||||
export interface ExportPackageResult {
|
||||
archive: Buffer;
|
||||
/** Undefined when talking to an older server that doesn't send the counts header. */
|
||||
counts?: ExportPackageCounts;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
|
|
@ -78,6 +93,7 @@ export class N8nClient {
|
|||
query?: Record<string, string>;
|
||||
formData?: FormData;
|
||||
responseType?: 'json' | 'binary';
|
||||
onResponse?: (response: Response) => void;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const url = new URL(`${this.baseUrl}${path}`);
|
||||
|
|
@ -129,6 +145,8 @@ export class N8nClient {
|
|||
throw new ApiError(response.status, message, hint, errorBody);
|
||||
}
|
||||
|
||||
options.onResponse?.(response);
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
|
@ -437,7 +455,7 @@ export class N8nClient {
|
|||
|
||||
// ─── Packages (beta) ───────────────────────────────────────────
|
||||
|
||||
async exportPackage(fields: ExportPackageFields): Promise<Buffer> {
|
||||
async exportPackage(fields: ExportPackageFields): Promise<ExportPackageResult> {
|
||||
// Empty collections are dropped so the API's per-field "at least one" rule isn't tripped.
|
||||
const body: {
|
||||
workflowIds?: string[];
|
||||
|
|
@ -454,10 +472,26 @@ export class N8nClient {
|
|||
if (fields.missingWorkflowDependencyPolicy)
|
||||
body.missingWorkflowDependencyPolicy = fields.missingWorkflowDependencyPolicy;
|
||||
|
||||
return await this.request<Buffer>('POST', '/n8n-packages/export', {
|
||||
let counts: ExportPackageCounts | undefined;
|
||||
const archive = await this.request<Buffer>('POST', '/n8n-packages/export', {
|
||||
body,
|
||||
responseType: 'binary',
|
||||
// Older servers omit this header; counts then stays undefined.
|
||||
onResponse: (response) => {
|
||||
const header = response.headers.get('X-N8n-Export-Counts');
|
||||
if (header) counts = this.parseExportCounts(header);
|
||||
},
|
||||
});
|
||||
|
||||
return { archive, counts };
|
||||
}
|
||||
|
||||
private parseExportCounts(header: string): ExportPackageCounts | undefined {
|
||||
try {
|
||||
return JSON.parse(header) as ExportPackageCounts;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async importPackage(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,24 @@ import * as fs from 'node:fs';
|
|||
|
||||
import { toPackagesError } from './shared';
|
||||
import { BaseCommand } from '../../base-command';
|
||||
import type { ExportPackageCounts, ExportPackageResult } from '../../client';
|
||||
|
||||
/**
|
||||
* Human-readable summary of an export, built from the real per-entity counts.
|
||||
* Categories with a zero count are omitted so a workflow-only export never
|
||||
* appends a spurious "0 folder(s)" and a folder export reports its bundled
|
||||
* workflows.
|
||||
*/
|
||||
function describeExport(counts: ExportPackageCounts & { projects?: number }): string {
|
||||
const parts: string[] = [];
|
||||
if (counts.projects) parts.push(`${counts.projects} project(s)`);
|
||||
if (counts.workflows) parts.push(`${counts.workflows} workflow(s)`);
|
||||
if (counts.folders) parts.push(`${counts.folders} folder(s)`);
|
||||
if (counts.credentials) parts.push(`${counts.credentials} credential(s)`);
|
||||
if (counts.dataTables) parts.push(`${counts.dataTables} data table(s)`);
|
||||
if (counts.variables) parts.push(`${counts.variables} variable(s)`);
|
||||
return parts.length > 0 ? parts.join(', ') : 'nothing';
|
||||
}
|
||||
|
||||
export default class PackageExport extends BaseCommand {
|
||||
static override description = 'Export workflows, folders, or projects as an n8n package (.n8np)';
|
||||
|
|
@ -76,9 +94,9 @@ export default class PackageExport extends BaseCommand {
|
|||
|
||||
await this.execute(async () => {
|
||||
const client = this.getClient(flags);
|
||||
let archive: Buffer;
|
||||
let result: ExportPackageResult;
|
||||
try {
|
||||
archive = await client.exportPackage(
|
||||
result = await client.exportPackage(
|
||||
projectIds.length > 0
|
||||
? { projectIds, includeVariableValues, missingWorkflowDependencyPolicy }
|
||||
: { workflowIds, folderIds, includeVariableValues, missingWorkflowDependencyPolicy },
|
||||
|
|
@ -86,21 +104,33 @@ export default class PackageExport extends BaseCommand {
|
|||
} catch (error) {
|
||||
throw toPackagesError(error);
|
||||
}
|
||||
fs.writeFileSync(flags.output, archive);
|
||||
fs.writeFileSync(flags.output, result.archive);
|
||||
|
||||
const { counts } = result;
|
||||
|
||||
if (projectIds.length > 0) {
|
||||
this.succeed(`Exported ${projectIds.length} project(s) to ${flags.output}`, flags, {
|
||||
// Older servers omit counts; fall back to the requested project id count.
|
||||
const summary = counts
|
||||
? describeExport({ ...counts, projects: projectIds.length })
|
||||
: `${projectIds.length} project(s)`;
|
||||
this.succeed(`Exported ${summary} to ${flags.output}`, flags, {
|
||||
output: flags.output,
|
||||
projectIds,
|
||||
...(counts ? { counts } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.succeed(
|
||||
`Exported ${workflowIds.length} workflow(s) and ${folderIds.length} folder(s) to ${flags.output}`,
|
||||
flags,
|
||||
{ output: flags.output, workflowIds, folderIds },
|
||||
);
|
||||
// Older servers omit counts; fall back to the requested id counts.
|
||||
const summary = counts
|
||||
? describeExport(counts)
|
||||
: `${workflowIds.length} workflow(s) and ${folderIds.length} folder(s)`;
|
||||
this.succeed(`Exported ${summary} to ${flags.output}`, flags, {
|
||||
output: flags.output,
|
||||
workflowIds,
|
||||
folderIds,
|
||||
...(counts ? { counts } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ describe('folder package export', () => {
|
|||
const project = await createTeamProject('Project A', owner);
|
||||
const folder = await createFolder(project, { name: 'to_production' });
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [],
|
||||
folderIds: [folder.id],
|
||||
|
|
@ -119,7 +119,7 @@ describe('folder package export', () => {
|
|||
const toProduction = await createFolder(project, { name: 'to_production' });
|
||||
const inProgress = await createFolder(project, { name: 'in_progress' });
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [],
|
||||
folderIds: [toProduction.id, inProgress.id],
|
||||
|
|
@ -178,7 +178,7 @@ describe('folder package export', () => {
|
|||
subWorkflowId: dependency.id,
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
folderIds: [exportedFolder.id],
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
|
|
@ -235,7 +235,7 @@ describe('folder package export', () => {
|
|||
subWorkflowId: externalChild.id,
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
folderIds: [folder.id],
|
||||
workflowIds: [externalChild.id],
|
||||
|
|
@ -271,7 +271,7 @@ describe('folder package export', () => {
|
|||
subWorkflowId: dependency.id,
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
folderIds: [exportedFolder.id],
|
||||
workflowIds: [topLevelParent.id],
|
||||
|
|
@ -296,7 +296,7 @@ describe('folder package export', () => {
|
|||
const child = await createFolder(project, { name: 'nested', parentFolder: parent });
|
||||
const grandchild = await createFolder(project, { name: 'deep', parentFolder: child });
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [],
|
||||
folderIds: [parent.id],
|
||||
|
|
@ -329,7 +329,7 @@ describe('folder package export', () => {
|
|||
createdAt: new Date('2026-02-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [],
|
||||
folderIds: [older.id, newer.id],
|
||||
|
|
@ -350,7 +350,7 @@ describe('folder package export', () => {
|
|||
const child = await createFolder(project, { name: 'nested', parentFolder: parent });
|
||||
|
||||
// Request only the child; its real parent stays behind.
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [],
|
||||
folderIds: [child.id],
|
||||
|
|
@ -401,7 +401,7 @@ describe('folder package export — with contained workflows', () => {
|
|||
const folder = await createFolder(project, { name: 'in_progress' });
|
||||
const workflow = await createWorkflow({ name: 'triage', parentFolder: folder }, project);
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [],
|
||||
folderIds: [folder.id],
|
||||
|
|
@ -424,7 +424,7 @@ describe('folder package export — with contained workflows', () => {
|
|||
const child = await createFolder(project, { name: 'nested', parentFolder: parent });
|
||||
const workflow = await createWorkflow({ name: 'playground', parentFolder: child }, project);
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [],
|
||||
folderIds: [parent.id],
|
||||
|
|
@ -453,7 +453,7 @@ describe('folder package export — with contained workflows', () => {
|
|||
parentFolder: folder,
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [],
|
||||
folderIds: [folder.id],
|
||||
|
|
@ -495,7 +495,7 @@ describe('folder package export — with contained workflows', () => {
|
|||
parentFolder: folder,
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [],
|
||||
folderIds: [folder.id],
|
||||
|
|
@ -550,7 +550,7 @@ describe('folder package export — with contained workflows', () => {
|
|||
const folder = await createFolder(project, { name: 'in_progress' });
|
||||
const workflow = await createWorkflow({ name: 'triage', parentFolder: folder }, project);
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [workflow.id],
|
||||
folderIds: [folder.id],
|
||||
|
|
@ -574,7 +574,7 @@ describe('folder package export — with contained workflows', () => {
|
|||
const project = await createTeamProject('Project A', owner);
|
||||
const folder = await createFolder(project, { name: 'empty' });
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [],
|
||||
folderIds: [folder.id],
|
||||
|
|
@ -594,7 +594,7 @@ describe('folder package export — with contained workflows', () => {
|
|||
const workflow = await createWorkflow({ name: 'triage', parentFolder: parent }, project);
|
||||
const subfolder = await createFolder(project, { name: 'workflows', parentFolder: parent });
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [],
|
||||
folderIds: [parent.id],
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ beforeEach(async () => {
|
|||
});
|
||||
|
||||
async function exportProjects(user: User, projectIds: string[]) {
|
||||
return await readExport(await service.exportPackage({ user, projectIds }));
|
||||
return await readExport((await service.exportPackage({ user, projectIds })).stream);
|
||||
}
|
||||
|
||||
async function exportProject(user: User, projectId: string) {
|
||||
|
|
@ -177,13 +177,12 @@ describe('project package export', () => {
|
|||
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
|
||||
|
||||
try {
|
||||
const { manifest, entries } = await readExport(
|
||||
await service.exportPackage({
|
||||
user: owner,
|
||||
projectIds: [projectA.id],
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
}),
|
||||
);
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
projectIds: [projectA.id],
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
});
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
const projectBEntry = manifest.projects!.find(({ id }) => id === projectB.id)!;
|
||||
expect(projectBEntry.target).toMatch(/^projects\//);
|
||||
|
|
@ -220,13 +219,12 @@ describe('project package export', () => {
|
|||
subWorkflowId: dependency.id,
|
||||
});
|
||||
|
||||
const { manifest } = await readExport(
|
||||
await service.exportPackage({
|
||||
user: owner,
|
||||
projectIds: [projectA.id],
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
}),
|
||||
);
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
projectIds: [projectA.id],
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
});
|
||||
const { manifest } = await readExport(stream);
|
||||
|
||||
const projectBEntry = manifest.projects!.find(({ id }) => id === projectB.id)!;
|
||||
const dependencyEntry = manifest.workflows!.find(({ id }) => id === dependency.id)!;
|
||||
|
|
@ -245,13 +243,12 @@ describe('project package export', () => {
|
|||
subWorkflowId: dependency.id,
|
||||
});
|
||||
|
||||
const { manifest } = await readExport(
|
||||
await service.exportPackage({
|
||||
user: owner,
|
||||
projectIds: [projectA.id],
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
}),
|
||||
);
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
projectIds: [projectA.id],
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
});
|
||||
const { manifest } = await readExport(stream);
|
||||
|
||||
const projectBEntry = manifest.projects!.find(({ id }) => id === projectB.id)!;
|
||||
const dependencyEntry = manifest.workflows!.find(({ id }) => id === dependency.id)!;
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ describe('workflow package export — with credentials', () => {
|
|||
credential,
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ 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([
|
||||
|
|
@ -124,7 +124,7 @@ describe('workflow package export — with credentials', () => {
|
|||
credential,
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [wfA.id, wfB.id],
|
||||
});
|
||||
|
|
@ -164,7 +164,10 @@ describe('workflow package export — with credentials', () => {
|
|||
subWorkflowId: child.id,
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: owner, workflowIds: [parent.id, child.id] });
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [parent.id, child.id],
|
||||
});
|
||||
const { manifest } = await readExport(stream);
|
||||
|
||||
expect(manifest.workflows!.map(({ id }) => id).sort()).toEqual([parent.id, child.id].sort());
|
||||
|
|
@ -191,7 +194,7 @@ describe('workflow package export — with credentials', () => {
|
|||
credentialType: 'httpHeaderAuth',
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ 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();
|
||||
|
|
@ -235,7 +238,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.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: sharee,
|
||||
workflowIds: [workflow.id],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ describe('workflow package export — with data tables', () => {
|
|||
references: [{ dataTableId: dataTable.id }],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest.requirements).toEqual({
|
||||
|
|
@ -116,7 +116,7 @@ describe('workflow package export — with data tables', () => {
|
|||
references: [{ dataTableId: dataTable.id }],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { entries } = await readExport(stream);
|
||||
|
||||
const dataTableFile = entries.find(
|
||||
|
|
@ -147,7 +147,7 @@ describe('workflow package export — with data tables', () => {
|
|||
references: [{ dataTableId: dataTable.id }],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { entries } = await readExport(stream);
|
||||
|
||||
const dataTableFile = entries.find((e) => e.name === 'data-tables/withrows/data-table.json');
|
||||
|
|
@ -174,7 +174,7 @@ describe('workflow package export — with data tables', () => {
|
|||
references: [{ dataTableId: dataTable.id, mode: 'list' }],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest.requirements?.dataTables).toEqual([
|
||||
|
|
@ -203,7 +203,7 @@ describe('workflow package export — with data tables', () => {
|
|||
subWorkflowId: child.id,
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [parent.id],
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
|
|
@ -237,7 +237,7 @@ describe('workflow package export — with data tables', () => {
|
|||
references: [{ dataTableId: dataTable.id }],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [wfA.id, wfB.id],
|
||||
});
|
||||
|
|
@ -268,7 +268,7 @@ describe('workflow package export — with data tables', () => {
|
|||
],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest.requirements?.dataTables?.map((d) => d.id).sort()).toEqual(
|
||||
|
|
@ -300,7 +300,7 @@ describe('workflow package export — with data tables', () => {
|
|||
references: [{ dataTableId: tableB.id }],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [wfA.id, wfB.id],
|
||||
});
|
||||
|
|
@ -326,7 +326,10 @@ describe('workflow package export — with data tables', () => {
|
|||
references: [{ dataTableId: dataTable.id }],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: owner, projectIds: [salesProject.id] });
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
projectIds: [salesProject.id],
|
||||
});
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
const projectTarget = manifest.projects![0].target;
|
||||
|
|
@ -355,7 +358,7 @@ describe('workflow package export — with data tables', () => {
|
|||
parentFolder: folder,
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [],
|
||||
folderIds: [folder.id],
|
||||
|
|
@ -392,7 +395,7 @@ describe('workflow package export — with data tables', () => {
|
|||
references: [{ dataTableId: dataTable.id }],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { manifest } = await readExport(stream);
|
||||
|
||||
expect(manifest.requirements?.dataTables).toEqual([
|
||||
|
|
@ -418,7 +421,7 @@ describe('workflow package export — with data tables', () => {
|
|||
references: [{ dataTableId: dataTable.id }],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: viewer, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: viewer, workflowIds: [workflow.id] });
|
||||
const { manifest } = await readExport(stream);
|
||||
|
||||
expect(manifest.requirements?.dataTables).toEqual([
|
||||
|
|
@ -443,7 +446,7 @@ describe('workflow package export — with data tables', () => {
|
|||
references: [{ dataTableId: dataTable.id }],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: member, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: member, workflowIds: [workflow.id] });
|
||||
const { manifest } = await readExport(stream);
|
||||
|
||||
expect(manifest.requirements?.dataTables).toEqual([
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ describe('workflow package export — with variables', () => {
|
|||
variableNames: ['API_URL'],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest.variables).toEqual([
|
||||
|
|
@ -106,7 +106,7 @@ describe('workflow package export — with variables', () => {
|
|||
variableNames: ['API_URL'],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [workflow.id],
|
||||
includeVariableValues: false,
|
||||
|
|
@ -145,7 +145,7 @@ describe('workflow package export — with variables', () => {
|
|||
variableNames: ['API_URL'],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: member, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: member, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest.variables).toEqual([
|
||||
|
|
@ -190,7 +190,7 @@ describe('workflow package export — with variables', () => {
|
|||
variableNames: ['API_URL'],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: member, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: member, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest).not.toHaveProperty('variables');
|
||||
|
|
@ -218,7 +218,7 @@ describe('workflow package export — with variables', () => {
|
|||
variableNames: ['API_URL'],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
// The catalog carries the project-scoped variable's id, not the global one's.
|
||||
|
|
@ -261,7 +261,7 @@ describe('workflow package export — with variables', () => {
|
|||
variableNames: ['SHARED_VAR'],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [wfA.id, wfB.id],
|
||||
});
|
||||
|
|
@ -285,7 +285,7 @@ describe('workflow package export — with variables', () => {
|
|||
variableNames: ['legacy-key'],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest.variables).toEqual([
|
||||
|
|
@ -311,7 +311,7 @@ describe('workflow package export — with variables', () => {
|
|||
variableNames: ['DOES_NOT_EXIST'],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest).not.toHaveProperty('variables');
|
||||
|
|
@ -338,7 +338,7 @@ describe('workflow package export — with variables', () => {
|
|||
const sharee = await createMember();
|
||||
await shareWorkflowWithUsers(workflow, [sharee]);
|
||||
|
||||
const stream = await service.exportPackage({ user: sharee, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: sharee, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest).not.toHaveProperty('variables');
|
||||
|
|
@ -388,7 +388,7 @@ describe('workflow package export — with variables', () => {
|
|||
variableNames: ['PROJECT_ONLY'],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [wfA.id, wfB.id],
|
||||
});
|
||||
|
|
@ -418,7 +418,7 @@ describe('workflow package export — with variables', () => {
|
|||
variableNames: ['API_URL'],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: owner, projectIds: [project.id] });
|
||||
const { stream } = await service.exportPackage({ user: owner, projectIds: [project.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
const projectEntry = manifest.projects!.find((entry) => entry.id === project.id)!;
|
||||
|
|
@ -448,7 +448,7 @@ describe('workflow package export — with variables', () => {
|
|||
// The sharee can reach the workflow via the direct share, but has no
|
||||
// access to the owner project's variables. The export must still succeed
|
||||
// with a requirements-only entry carrying no value.
|
||||
const stream = await service.exportPackage({ user: sharee, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: sharee, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest).not.toHaveProperty('variables');
|
||||
|
|
@ -488,7 +488,7 @@ describe('workflow package export — with variables', () => {
|
|||
variableNames: [],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [workflow.id],
|
||||
canExportVariableValues: false,
|
||||
|
|
@ -509,7 +509,7 @@ describe('workflow package export — with variables', () => {
|
|||
variableNames: ['API_URL'],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [workflow.id],
|
||||
includeVariableValues: false,
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ describe('workflow package export', () => {
|
|||
});
|
||||
|
||||
async function exportSingleWorkflow(user: User, workflowId: string) {
|
||||
const stream = await service.exportPackage({ user, workflowIds: [workflowId] });
|
||||
const { stream } = await service.exportPackage({ user, workflowIds: [workflowId] });
|
||||
return await readExport(stream);
|
||||
}
|
||||
|
||||
|
|
@ -100,7 +100,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.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [wfA.id, wfB.id],
|
||||
});
|
||||
|
|
@ -149,7 +149,7 @@ describe('workflow package export', () => {
|
|||
project,
|
||||
);
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [wfA.id, wfB.id],
|
||||
});
|
||||
|
|
@ -173,7 +173,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.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [wfA.id, wfB.id],
|
||||
});
|
||||
|
|
@ -220,7 +220,7 @@ describe('workflow package export', () => {
|
|||
'2 workflow dependencies not included in the package',
|
||||
);
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [workflowA.id, workflowB.id, workflowC.id],
|
||||
});
|
||||
|
|
@ -274,7 +274,7 @@ describe('workflow package export', () => {
|
|||
service.exportPackage({ user: owner, workflowIds: [parent.id] }),
|
||||
).rejects.toThrow('workflow dependency not included in the package');
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [parent.id, child.id],
|
||||
});
|
||||
|
|
@ -326,7 +326,7 @@ describe('workflow package export', () => {
|
|||
service.exportPackage({ user: owner, workflowIds: [parent.id] }),
|
||||
).rejects.toThrow('workflow dependency not included in the package');
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [parent.id, child.id],
|
||||
});
|
||||
|
|
@ -364,7 +364,7 @@ describe('workflow package export', () => {
|
|||
service.exportPackage({ user: owner, workflowIds: [parent.id] }),
|
||||
).rejects.toThrow('workflow dependency not included in the package');
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [parent.id, errorHandler.id],
|
||||
});
|
||||
|
|
@ -393,7 +393,7 @@ describe('workflow package export', () => {
|
|||
subWorkflowId: child.id,
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [parentA.id, parentB.id, child.id],
|
||||
});
|
||||
|
|
@ -473,7 +473,7 @@ describe('workflow package export', () => {
|
|||
subWorkflowId: workflowB.id,
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [workflowA.id],
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
|
|
@ -554,7 +554,7 @@ describe('workflow package export', () => {
|
|||
];
|
||||
await Container.get(WorkflowRepository).save(workflowA);
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [workflowA.id],
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
|
|
@ -591,7 +591,7 @@ describe('workflow package export', () => {
|
|||
];
|
||||
await Container.get(WorkflowRepository).save(workflowA);
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [workflowA.id, workflowB.id],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ describe('workflow package import — with data tables', () => {
|
|||
references: [{ dataTableId: sourceTable.id }],
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const packageBuffer = await streamToBuffer(stream);
|
||||
|
||||
// Simulate importing on another instance: ids are global, so the source
|
||||
|
|
@ -715,7 +715,7 @@ describe('workflow package import — with data tables', () => {
|
|||
parentFolder: folder,
|
||||
});
|
||||
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [],
|
||||
folderIds: [folder.id],
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ async function importPackage(params: ImportParams) {
|
|||
}
|
||||
|
||||
async function exportWorkflowPackage(user: User, workflowId: string): Promise<Buffer> {
|
||||
const stream = await service.exportPackage({
|
||||
const { stream } = await service.exportPackage({
|
||||
user,
|
||||
workflowIds: [workflowId],
|
||||
includeVariableValues: true,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
import { N8N_VERSION } from '@/constants';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
|
|
@ -31,7 +30,9 @@ import { TarPackageWriter } from './io/tar/tar-package-writer';
|
|||
import { PackageImportConfig } from './n8n-packages.config';
|
||||
import {
|
||||
MissingWorkflowDependencyPolicy,
|
||||
type ExportPackageEventCounts,
|
||||
type ExportPackageRequest,
|
||||
type ExportPackageResult,
|
||||
type ImportPackageRequest,
|
||||
type ImportResult,
|
||||
} from './n8n-packages.types';
|
||||
|
|
@ -64,7 +65,7 @@ export class N8nPackagesService {
|
|||
private readonly autoIncludedWorkflowExporter: AutoIncludedWorkflowExporter,
|
||||
) {}
|
||||
|
||||
async exportPackage(request: ExportPackageRequest): Promise<Readable> {
|
||||
async exportPackage(request: ExportPackageRequest): Promise<ExportPackageResult> {
|
||||
// TODO: remove this once reference-only is supported
|
||||
const { missingWorkflowDependencyPolicy } = request;
|
||||
if (missingWorkflowDependencyPolicy === MissingWorkflowDependencyPolicy.ReferenceOnly) {
|
||||
|
|
@ -249,6 +250,14 @@ export class N8nPackagesService {
|
|||
|
||||
const stream = writer.finalize();
|
||||
|
||||
const counts: ExportPackageEventCounts = {
|
||||
workflows: allWorkflowsInPackage.length,
|
||||
folders: allFolders.length,
|
||||
credentials: credentialExportResult.entries.length,
|
||||
dataTables: dataTableExportResult.entries.length,
|
||||
variables: variableExportResult.entries.length,
|
||||
};
|
||||
|
||||
this.eventService.emit('n8n-package-exported', {
|
||||
user: request.user,
|
||||
...(allWorkflowsInPackage.length
|
||||
|
|
@ -256,16 +265,10 @@ export class N8nPackagesService {
|
|||
: {}),
|
||||
...(allFolders.length ? { folderIds: allFolders.map(({ id }) => id) } : {}),
|
||||
...(allProjects.length ? { projectIds: allProjects.map(({ id }) => id) } : {}),
|
||||
counts: {
|
||||
workflows: allWorkflowsInPackage.length,
|
||||
folders: allFolders.length,
|
||||
credentials: credentialExportResult.entries.length,
|
||||
dataTables: dataTableExportResult.entries.length,
|
||||
variables: variableExportResult.entries.length,
|
||||
},
|
||||
counts,
|
||||
});
|
||||
|
||||
return stream;
|
||||
return { stream, counts };
|
||||
}
|
||||
|
||||
async importPackage(request: ImportPackageRequest): Promise<ImportResult> {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { User } from '@n8n/db';
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
import type { DataTableResolutionFailure } from './entities/data-table/data-table.types';
|
||||
import type { VariableResolutionFailure } from './entities/variable/variable.types';
|
||||
|
|
@ -222,6 +223,16 @@ export type ExportPackageEventCounts = {
|
|||
variables: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of an export: the archive stream plus the true per-entity counts of
|
||||
* what actually ended up in the package (after folder bundling and
|
||||
* auto-inclusion). Consumers surface these instead of the requested id counts.
|
||||
*/
|
||||
export interface ExportPackageResult {
|
||||
stream: Readable;
|
||||
counts: ExportPackageEventCounts;
|
||||
}
|
||||
|
||||
export interface ImportedWorkflowSummary {
|
||||
sourceWorkflowId: string;
|
||||
localId: string;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,14 @@ beforeAll(async () => {
|
|||
importPackage = handler.importPackage[1];
|
||||
});
|
||||
|
||||
const EXPORT_COUNTS = {
|
||||
workflows: 2,
|
||||
folders: 1,
|
||||
credentials: 0,
|
||||
dataTables: 0,
|
||||
variables: 0,
|
||||
};
|
||||
|
||||
describe('n8n-packages handler', () => {
|
||||
let mockService: Mocked<N8nPackagesService>;
|
||||
let mockEventService: Mocked<EventService>;
|
||||
|
|
@ -225,7 +233,7 @@ describe('n8n-packages handler', () => {
|
|||
|
||||
it('does not reject upfront without variable:list scope; forwards canExportVariableValues=false for the service to enforce', async () => {
|
||||
const stream = new PassThrough();
|
||||
mockService.exportPackage.mockResolvedValue(stream);
|
||||
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
|
||||
const res = makeResponse();
|
||||
|
||||
const resultPromise = run(makeRequest({ workflowIds: ['wf-1'] }, ['workflow:export']), res);
|
||||
|
|
@ -246,7 +254,7 @@ describe('n8n-packages handler', () => {
|
|||
|
||||
it('allows value-less export without variable:list scope', async () => {
|
||||
const stream = new PassThrough();
|
||||
mockService.exportPackage.mockResolvedValue(stream);
|
||||
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
|
||||
const res = makeResponse();
|
||||
|
||||
const resultPromise = run(
|
||||
|
|
@ -344,7 +352,7 @@ describe('n8n-packages handler', () => {
|
|||
|
||||
it('streams the export for a valid workflow request', async () => {
|
||||
const stream = new PassThrough();
|
||||
mockService.exportPackage.mockResolvedValue(stream);
|
||||
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
|
||||
const res = makeResponse();
|
||||
|
||||
const resultPromise = run(
|
||||
|
|
@ -369,12 +377,20 @@ describe('n8n-packages handler', () => {
|
|||
'Content-Disposition',
|
||||
'attachment; filename="export.n8np"',
|
||||
);
|
||||
expect(res.setHeader).toHaveBeenCalledWith(
|
||||
'X-N8n-Export-Counts',
|
||||
JSON.stringify(EXPORT_COUNTS),
|
||||
);
|
||||
expect(res.setHeader).toHaveBeenCalledWith(
|
||||
'Access-Control-Expose-Headers',
|
||||
'X-N8n-Export-Counts',
|
||||
);
|
||||
expect(mockEventService.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards a non-default missing workflow dependency policy', async () => {
|
||||
const stream = new PassThrough();
|
||||
mockService.exportPackage.mockResolvedValue(stream);
|
||||
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
|
||||
const res = makeResponse();
|
||||
|
||||
const resultPromise = run(
|
||||
|
|
@ -404,7 +420,7 @@ describe('n8n-packages handler', () => {
|
|||
|
||||
it('streams the export for a valid project request', async () => {
|
||||
const stream = new PassThrough();
|
||||
mockService.exportPackage.mockResolvedValue(stream);
|
||||
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
|
||||
const res = makeResponse();
|
||||
|
||||
const resultPromise = run(
|
||||
|
|
@ -428,7 +444,7 @@ describe('n8n-packages handler', () => {
|
|||
|
||||
it('streams the export for a valid folder request', async () => {
|
||||
const stream = new PassThrough();
|
||||
mockService.exportPackage.mockResolvedValue(stream);
|
||||
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
|
||||
const res = makeResponse();
|
||||
|
||||
const resultPromise = run(
|
||||
|
|
@ -452,7 +468,7 @@ describe('n8n-packages handler', () => {
|
|||
|
||||
it('forwards includeVariableValues=false to the service', async () => {
|
||||
const stream = new PassThrough();
|
||||
mockService.exportPackage.mockResolvedValue(stream);
|
||||
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
|
||||
const res = makeResponse();
|
||||
|
||||
const resultPromise = run(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { Container } from '@n8n/di';
|
|||
import type { ApiKeyScope } from '@n8n/permissions';
|
||||
import type { Response } from 'express';
|
||||
import { UserError } from 'n8n-workflow';
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
|
|
@ -14,6 +13,7 @@ import {
|
|||
PackageEntityNotFoundError,
|
||||
} from '@/modules/n8n-packages/entities/package-export.errors';
|
||||
import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service';
|
||||
import type { ExportPackageResult } from '@/modules/n8n-packages/n8n-packages.types';
|
||||
import { classifyPackageFailure } from '@/modules/n8n-packages/package-failure-classifier';
|
||||
import { resolveImportPackageUpload } from '@/modules/n8n-packages/utils/import-package-upload';
|
||||
|
||||
|
|
@ -23,6 +23,9 @@ import { publicApiCompositeScope } from '../../shared/middlewares/global.middlew
|
|||
|
||||
const PACKAGE_EXPORT_SCOPES = 'project:export,workflow:export';
|
||||
|
||||
/** Header carrying the JSON-serialized true per-entity counts of the exported package. */
|
||||
const EXPORT_COUNTS_HEADER = 'X-N8n-Export-Counts';
|
||||
|
||||
type ExportPackageRequest = AuthenticatedRequest<
|
||||
{},
|
||||
{},
|
||||
|
|
@ -80,9 +83,15 @@ function assertPackageImportApiKeyScopes(req: AuthenticatedRequest) {
|
|||
}
|
||||
}
|
||||
|
||||
async function streamPackageExport(res: Response, stream: Readable): Promise<Response> {
|
||||
async function streamPackageExport(
|
||||
res: Response,
|
||||
{ stream, counts }: ExportPackageResult,
|
||||
): Promise<Response> {
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="export.n8np"');
|
||||
res.setHeader(EXPORT_COUNTS_HEADER, JSON.stringify(counts));
|
||||
// Cross-origin browser clients can only read the counts header if it's exposed.
|
||||
res.setHeader('Access-Control-Expose-Headers', EXPORT_COUNTS_HEADER);
|
||||
|
||||
return await new Promise<Response>((resolve, reject) => {
|
||||
stream.on('error', reject);
|
||||
|
|
@ -131,7 +140,7 @@ const n8nPackagesHandlers: N8nPackagesHandlers = {
|
|||
projectIds,
|
||||
);
|
||||
|
||||
const stream = await Container.get(N8nPackagesService).exportPackage({
|
||||
const exportResult = await Container.get(N8nPackagesService).exportPackage({
|
||||
user: req.user,
|
||||
workflowIds,
|
||||
folderIds,
|
||||
|
|
@ -141,7 +150,7 @@ const n8nPackagesHandlers: N8nPackagesHandlers = {
|
|||
missingWorkflowDependencyPolicy: payload.data.missingWorkflowDependencyPolicy,
|
||||
});
|
||||
|
||||
return await streamPackageExport(res, stream);
|
||||
return await streamPackageExport(res, exportResult);
|
||||
} catch (error) {
|
||||
Container.get(EventService).emit('n8n-package-export-failed', {
|
||||
user: req.user,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,14 @@ post:
|
|||
responses:
|
||||
'200':
|
||||
description: A gzipped tar archive containing the exported package contents.
|
||||
headers:
|
||||
X-N8n-Export-Counts:
|
||||
description: |
|
||||
JSON-serialized per-entity counts of what actually ended up in the
|
||||
package (after folder bundling and auto-inclusion), e.g.
|
||||
`{"workflows":2,"folders":1,"credentials":0,"dataTables":0,"variables":0}`.
|
||||
schema:
|
||||
type: string
|
||||
content:
|
||||
application/gzip:
|
||||
schema:
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user