feat(core): Workflow export with credentials (#31241)

This commit is contained in:
James Gee 2026-06-01 10:42:34 +02:00 committed by GitHub
parent 3dfca93a37
commit 00431d7505
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1103 additions and 54 deletions

View File

@ -0,0 +1,216 @@
import {
createTeamProject,
shareWorkflowWithUsers,
testDb,
testModules,
} from '@n8n/backend-test-utils';
import { Container } from '@n8n/di';
import { jsonParse } from 'n8n-workflow';
import { saveCredential } from '@test-integration/db/credentials';
import { createMember, createOwner } from '@test-integration/db/users';
import { N8nPackagesService } from '../n8n-packages.service';
import { readExport } from './utils/tar-support';
import {
buildWorkflowReferencingCredential,
buildWorkflowReferencingCredentialById,
} from './utils/test-builders';
beforeAll(async () => {
await testModules.loadModules(['n8n-packages']);
await testDb.init();
});
afterAll(async () => {
await testDb.terminate();
});
beforeEach(async () => {
await testDb.truncate([
'WorkflowEntity',
'SharedWorkflow',
'CredentialsEntity',
'SharedCredentials',
'ProjectRelation',
'Project',
]);
});
describe('workflow package export — with credentials', () => {
let service: N8nPackagesService;
beforeAll(() => {
service = Container.get(N8nPackagesService);
});
it('bundles a referenced credential and lists it in manifest.credentials + requirements', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const credential = await saveCredential(
{
name: 'Header credential',
type: 'httpHeaderAuth',
data: { name: 'X-Auth', value: 'secret' },
},
{ project, role: 'credential:owner' },
);
const workflow = await buildWorkflowReferencingCredential({
name: 'Workflow with creds',
project,
credential,
});
const stream = await service.exportWorkflows({ user: owner, workflowIds: [workflow.id] });
const { manifest, entries } = await readExport(stream);
expect(manifest.credentials).toEqual([
{
id: credential.id,
name: credential.name,
target: expect.any(String) as string,
},
]);
expect(manifest.requirements).toEqual({
credentials: [
{
id: credential.id,
name: credential.name,
type: 'httpHeaderAuth',
usedByWorkflows: [workflow.id],
},
],
});
const credentialFile = entries.find(
(e) => e.name === `${manifest.credentials![0].target}/credential.json`,
);
expect(credentialFile).toBeDefined();
const parsed = jsonParse<Record<string, unknown>>(credentialFile!.content.toString());
expect(parsed).toEqual({
id: credential.id,
name: credential.name,
type: 'httpHeaderAuth',
});
// Secret-adjacent fields must not appear on disk under any name.
expect(Object.keys(parsed).sort()).toEqual(['id', 'name', 'type']);
});
it('dedupes a credential referenced by two workflows in a single export', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const credential = await saveCredential(
{
name: 'Shared credential',
type: 'httpHeaderAuth',
data: { name: 'X-Auth', value: 'secret' },
},
{ project, role: 'credential:owner' },
);
const wfA = await buildWorkflowReferencingCredential({
name: 'Workflow A',
project,
credential,
});
const wfB = await buildWorkflowReferencingCredential({
name: 'Workflow B',
project,
credential,
});
const stream = await service.exportWorkflows({
user: owner,
workflowIds: [wfA.id, wfB.id],
});
const { manifest, entries } = await readExport(stream);
expect(manifest.credentials).toHaveLength(1);
expect(manifest.credentials![0].id).toBe(credential.id);
expect(manifest.requirements?.credentials).toHaveLength(1);
expect(manifest.requirements!.credentials![0].usedByWorkflows.sort()).toEqual(
[wfA.id, wfB.id].sort(),
);
const credentialFiles = entries.filter((e) => e.name.endsWith('/credential.json'));
expect(credentialFiles).toHaveLength(1);
});
it('lists orphan credential references in requirements without writing a file', async () => {
const owner = await createOwner();
const project = await createTeamProject('Project A', owner);
const workflow = await buildWorkflowReferencingCredentialById({
name: 'Workflow with orphan',
project,
credentialId: 'does-not-exist',
credentialName: 'Stale cred name',
credentialType: 'httpHeaderAuth',
});
const stream = await service.exportWorkflows({ user: owner, workflowIds: [workflow.id] });
const { manifest, entries } = await readExport(stream);
expect(manifest.credentials).toBeUndefined();
expect(manifest.requirements).toEqual({
credentials: [
{
id: 'does-not-exist',
name: 'Stale cred name',
type: 'httpHeaderAuth',
usedByWorkflows: [workflow.id],
},
],
});
const credentialFiles = entries.filter((e) => e.name.endsWith('/credential.json'));
expect(credentialFiles).toEqual([]);
});
it('emits a requirements-only entry when the caller can read the workflow but not its credential', async () => {
const owner = await createOwner();
const ownerProject = await createTeamProject('Owner Project', owner);
const credential = await saveCredential(
{
name: 'Owner-only credential',
type: 'httpHeaderAuth',
data: { name: 'X-Auth', value: 'secret' },
},
{ project: ownerProject, role: 'credential:owner' },
);
const workflow = await buildWorkflowReferencingCredential({
name: 'Shared workflow with private cred',
project: ownerProject,
credential,
});
const sharee = await createMember();
await shareWorkflowWithUsers(workflow, [sharee]);
// The sharee can reach the workflow via the direct share, but the
// 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({
user: sharee,
workflowIds: [workflow.id],
});
const { manifest, entries } = await readExport(stream);
expect(manifest.credentials).toBeUndefined();
expect(manifest.requirements).toEqual({
credentials: [
{
id: credential.id,
name: credential.name,
type: 'httpHeaderAuth',
usedByWorkflows: [workflow.id],
},
],
});
const credentialFiles = entries.filter((e) => e.name.endsWith('/credential.json'));
expect(credentialFiles).toEqual([]);
});
});

View File

@ -0,0 +1,68 @@
import { createWorkflow } from '@n8n/backend-test-utils';
import type { CredentialsEntity, Project, WorkflowEntity } from '@n8n/db';
interface BuildWorkflowReferencingCredentialByIdOptions {
name: string;
project: Project;
credentialId: string;
credentialName: string;
credentialType: string;
}
/**
* Creates a one-node workflow whose HTTP Request node references a credential
* by id without requiring the credential row to exist. Useful for orphan and
* forbidden-access cases.
*/
export async function buildWorkflowReferencingCredentialById({
name,
project,
credentialId,
credentialName,
credentialType,
}: BuildWorkflowReferencingCredentialByIdOptions): Promise<WorkflowEntity> {
return await createWorkflow(
{
name,
nodes: [
{
id: 'n1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
position: [0, 0],
parameters: {},
credentials: {
[credentialType]: { id: credentialId, name: credentialName },
},
},
],
connections: {},
},
project,
);
}
interface BuildWorkflowReferencingCredentialOptions {
name: string;
project: Project;
credential: Pick<CredentialsEntity, 'id' | 'name' | 'type'>;
}
/**
* Convenience wrapper around `buildWorkflowReferencingCredentialById` for the
* common case of pointing at an already-saved credential.
*/
export async function buildWorkflowReferencingCredential({
name,
project,
credential,
}: BuildWorkflowReferencingCredentialOptions): Promise<WorkflowEntity> {
return await buildWorkflowReferencingCredentialById({
name,
project,
credentialId: credential.id,
credentialName: credential.name,
credentialType: credential.type,
});
}

View File

@ -0,0 +1,148 @@
import type { WorkflowEntity } from '@n8n/db';
import { CredentialRequirementsExtractor } from '../credential-requirements.extractor';
function makeWorkflow(overrides: Partial<WorkflowEntity> = {}): WorkflowEntity {
return {
id: 'wf-abc1234567',
name: 'My Workflow',
nodes: [],
connections: {},
versionId: 'v1',
active: false,
isArchived: false,
settings: undefined,
parentFolder: null,
...overrides,
} as unknown as WorkflowEntity;
}
describe('CredentialRequirementsExtractor', () => {
const extractor = new CredentialRequirementsExtractor();
it('returns no requirements for a workflow with no credentialled nodes', () => {
const workflow = makeWorkflow({
id: 'wf-no-creds',
nodes: [
{
id: 'n1',
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
});
expect(extractor.extract(workflow)).toEqual([]);
});
it('emits one requirement per node credential slot, keyed by credential type', () => {
// node.credentials is { [credentialTypeKey]: { id, name } } — the
// type comes from the map key, not the value.
const workflow = makeWorkflow({
id: 'wf-creds',
nodes: [
{
id: 'n1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
position: [0, 0],
parameters: {},
credentials: {
httpHeaderAuth: { id: 'cred-1', name: 'Header credential' },
httpBasicAuth: { id: 'cred-2', name: 'Basic credential' },
},
},
],
});
expect(extractor.extract(workflow)).toEqual(
expect.arrayContaining([
{
workflowId: 'wf-creds',
credentialId: 'cred-1',
credentialName: 'Header credential',
credentialType: 'httpHeaderAuth',
},
{
workflowId: 'wf-creds',
credentialId: 'cred-2',
credentialName: 'Basic credential',
credentialType: 'httpBasicAuth',
},
]),
);
expect(extractor.extract(workflow)).toHaveLength(2);
});
it('dedupes when the same credential id appears in two nodes of one workflow', () => {
const workflow = makeWorkflow({
id: 'wf-dup',
nodes: [
{
id: 'n1',
name: 'HTTP A',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
position: [0, 0],
parameters: {},
credentials: {
httpHeaderAuth: { id: 'cred-shared', name: 'Shared' },
},
},
{
id: 'n2',
name: 'HTTP B',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
position: [0, 0],
parameters: {},
credentials: {
httpHeaderAuth: { id: 'cred-shared', name: 'Shared' },
},
},
],
});
expect(extractor.extract(workflow)).toEqual([
{
workflowId: 'wf-dup',
credentialId: 'cred-shared',
credentialName: 'Shared',
credentialType: 'httpHeaderAuth',
},
]);
});
it('skips slots that have no credential id selected yet', () => {
const workflow = makeWorkflow({
id: 'wf-blank-slot',
nodes: [
{
id: 'n1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
position: [0, 0],
parameters: {},
credentials: {
httpHeaderAuth: { id: null as unknown as string, name: '' },
},
},
],
});
expect(extractor.extract(workflow)).toEqual([]);
});
it('returns an empty list when the workflow has no nodes array at all', () => {
// Defensive guard for older/partially-hydrated workflow rows where
// `nodes` may be absent rather than empty.
const workflow = makeWorkflow({ id: 'wf-no-nodes', nodes: undefined as unknown as [] });
expect(extractor.extract(workflow)).toEqual([]);
});
});

View File

@ -0,0 +1,238 @@
import type { CredentialsEntity, User } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import { jsonParse } from 'n8n-workflow';
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CapturingWriter } from '../../../io/__tests__/utils/capturing-writer';
import { CredentialExporter } from '../credential.exporter';
import { CredentialSerializer } from '../credential.serializer';
import type { WorkflowCredentialRequirement } from '../credential.types';
const user = mock<User>({ id: 'user-1' });
function makeCredential(overrides: Partial<CredentialsEntity> = {}): CredentialsEntity {
return {
id: 'cred-1',
name: 'My Credential',
type: 'httpHeaderAuth',
data: '',
isManaged: false,
isGlobal: false,
isResolvable: false,
resolvableAllowFallback: false,
resolverId: null,
shared: [],
...overrides,
} as unknown as CredentialsEntity;
}
function makeRequirement(
overrides: Partial<WorkflowCredentialRequirement> = {},
): WorkflowCredentialRequirement {
return {
workflowId: 'wf-1',
credentialId: 'cred-1',
credentialName: 'My Credential',
credentialType: 'httpHeaderAuth',
...overrides,
};
}
function makeExporter() {
const finder = mock<CredentialsFinderService>();
const exporter = new CredentialExporter(finder, new CredentialSerializer());
return { exporter, finder };
}
describe('CredentialExporter', () => {
describe('empty input', () => {
it('returns empty result and writes nothing when given no requirements', async () => {
const { exporter, finder } = makeExporter();
const writer = new CapturingWriter();
const result = await exporter.export({ user, requirements: [], writer });
expect(result).toEqual({ entries: [], requirements: [] });
expect(writer.files).toEqual([]);
expect(writer.directories).toEqual([]);
expect(finder.findCredentialForUser).not.toHaveBeenCalled();
});
});
describe('happy path', () => {
it('writes one accessible credential to its slugged folder and emits matching entry + requirement', async () => {
const { exporter, finder } = makeExporter();
finder.findCredentialForUser.mockResolvedValue(makeCredential());
const writer = new CapturingWriter();
const result = await exporter.export({
user,
requirements: [makeRequirement()],
writer,
});
expect(finder.findCredentialForUser).toHaveBeenCalledWith('cred-1', user, [
'credential:read',
]);
expect(result.entries).toEqual([
{ id: 'cred-1', name: 'My Credential', target: 'credentials/my-credential' },
]);
expect(result.requirements).toEqual([
{
id: 'cred-1',
name: 'My Credential',
type: 'httpHeaderAuth',
usedByWorkflows: ['wf-1'],
},
]);
expect(writer.directories).toEqual(['credentials/my-credential']);
expect(writer.files).toHaveLength(1);
expect(writer.files[0].path).toBe('credentials/my-credential/credential.json');
const parsed = jsonParse<Record<string, unknown>>(writer.files[0].content);
expect(parsed).toEqual({
id: 'cred-1',
name: 'My Credential',
type: 'httpHeaderAuth',
});
});
it('dedupes by credential id and aggregates usedByWorkflows when requirements come from multiple workflows', async () => {
const { exporter, finder } = makeExporter();
finder.findCredentialForUser.mockResolvedValue(makeCredential());
const writer = new CapturingWriter();
const result = await exporter.export({
user,
requirements: [
makeRequirement({ workflowId: 'wf-a' }),
makeRequirement({ workflowId: 'wf-b' }),
],
writer,
});
expect(finder.findCredentialForUser).toHaveBeenCalledTimes(1);
expect(result.entries).toEqual([
{ id: 'cred-1', name: 'My Credential', target: 'credentials/my-credential' },
]);
expect(result.requirements).toEqual([
{
id: 'cred-1',
name: 'My Credential',
type: 'httpHeaderAuth',
usedByWorkflows: ['wf-a', 'wf-b'],
},
]);
expect(writer.files).toHaveLength(1);
});
it('disambiguates targets when two credentials share a name', async () => {
const { exporter, finder } = makeExporter();
finder.findCredentialForUser
.mockResolvedValueOnce(makeCredential({ id: 'cred-a', name: 'Same Name' }))
.mockResolvedValueOnce(makeCredential({ id: 'cred-b', name: 'Same Name' }));
const writer = new CapturingWriter();
const result = await exporter.export({
user,
requirements: [
makeRequirement({ credentialId: 'cred-a', credentialName: 'Same Name' }),
makeRequirement({ credentialId: 'cred-b', credentialName: 'Same Name' }),
],
writer,
});
const targets = result.entries.map((e) => e.target);
expect(targets).toEqual(['credentials/same-name', 'credentials/same-name-2']);
const writtenPaths = writer.files.map((f) => f.path);
expect(writtenPaths).toContain('credentials/same-name/credential.json');
expect(writtenPaths).toContain('credentials/same-name-2/credential.json');
});
it('emits a requirements-only entry when the credential is unfindable for the caller', async () => {
// findCredentialForUser returns null for both "row missing" and
// "row exists but the caller lacks credential:read"; the unit
// can't (and shouldn't) distinguish them. Either way the
// exporter must still pass the credential:read scope so the
// finder applies the right policy.
const { exporter, finder } = makeExporter();
finder.findCredentialForUser.mockResolvedValue(null);
const writer = new CapturingWriter();
const result = await exporter.export({
user,
requirements: [
makeRequirement({
credentialId: 'cred-unavailable',
credentialName: 'Stale node name',
credentialType: 'httpHeaderAuth',
workflowId: 'wf-1',
}),
],
writer,
});
expect(finder.findCredentialForUser).toHaveBeenCalledWith('cred-unavailable', user, [
'credential:read',
]);
expect(result.entries).toEqual([]);
expect(result.requirements).toEqual([
{
id: 'cred-unavailable',
name: 'Stale node name',
type: 'httpHeaderAuth',
usedByWorkflows: ['wf-1'],
},
]);
expect(writer.files).toEqual([]);
expect(writer.directories).toEqual([]);
});
it('handles a mix of accessible and unavailable requirements in one call', async () => {
const { exporter, finder } = makeExporter();
finder.findCredentialForUser.mockImplementation(async (id) => {
await Promise.resolve();
return id === 'cred-1' ? makeCredential() : null;
});
const writer = new CapturingWriter();
const result = await exporter.export({
user,
requirements: [
makeRequirement({ credentialId: 'cred-1', workflowId: 'wf-1' }),
makeRequirement({
credentialId: 'cred-unavailable',
credentialName: 'Unavailable',
credentialType: 'slackOAuth2Api',
workflowId: 'wf-1',
}),
],
writer,
});
expect(result.entries).toEqual([
{ id: 'cred-1', name: 'My Credential', target: 'credentials/my-credential' },
]);
expect(result.requirements).toEqual([
{
id: 'cred-1',
name: 'My Credential',
type: 'httpHeaderAuth',
usedByWorkflows: ['wf-1'],
},
{
id: 'cred-unavailable',
name: 'Unavailable',
type: 'slackOAuth2Api',
usedByWorkflows: ['wf-1'],
},
]);
expect(writer.files).toHaveLength(1);
});
});
});

View File

@ -0,0 +1,58 @@
import type { CredentialsEntity } from '@n8n/db';
import { CredentialSerializer } from '../credential.serializer';
function makeCredential(overrides: Partial<CredentialsEntity> = {}): CredentialsEntity {
return {
id: 'cred-1',
name: 'My Credential',
type: 'httpHeaderAuth',
data: 'encrypted-blob-do-not-export',
isManaged: false,
isGlobal: false,
isResolvable: false,
resolvableAllowFallback: false,
resolverId: null,
createdAt: new Date(),
updatedAt: new Date(),
shared: [],
...overrides,
} as unknown as CredentialsEntity;
}
describe('CredentialSerializer', () => {
const serializer = new CredentialSerializer();
it('returns exactly id, name, type', () => {
const credential = makeCredential();
const serialized = serializer.serialize(credential);
expect(Object.keys(serialized).sort()).toEqual(['id', 'name', 'type']);
expect(serialized).toEqual({
id: 'cred-1',
name: 'My Credential',
type: 'httpHeaderAuth',
});
});
it('does not leak encrypted data or secret-adjacent flags', () => {
const credential = makeCredential({
data: 'sensitive-encrypted-payload',
isManaged: true,
isGlobal: true,
});
const serialized = serializer.serialize(credential);
const serializedAsRecord = serialized as unknown as Record<string, unknown>;
expect(serializedAsRecord.data).toBeUndefined();
expect(serializedAsRecord.isManaged).toBeUndefined();
expect(serializedAsRecord.isGlobal).toBeUndefined();
expect(serializedAsRecord.isResolvable).toBeUndefined();
expect(serializedAsRecord.resolverId).toBeUndefined();
expect(serializedAsRecord.shared).toBeUndefined();
expect(serializedAsRecord.createdAt).toBeUndefined();
expect(serializedAsRecord.updatedAt).toBeUndefined();
});
});

View File

@ -0,0 +1,29 @@
import type { WorkflowEntity } from '@n8n/db';
import { Service } from '@n8n/di';
import type { WorkflowCredentialRequirement } from './credential.types';
import type { RequirementsExtractor } from '../requirements-extractor';
@Service()
export class CredentialRequirementsExtractor
implements RequirementsExtractor<WorkflowCredentialRequirement>
{
extract(workflow: WorkflowEntity): WorkflowCredentialRequirement[] {
const byId = new Map<string, WorkflowCredentialRequirement>();
for (const node of workflow.nodes ?? []) {
for (const [credentialType, details] of Object.entries(node.credentials ?? {})) {
if (!details?.id || byId.has(details.id)) continue;
byId.set(details.id, {
workflowId: workflow.id,
credentialId: details.id,
credentialName: details.name,
credentialType,
});
}
}
return [...byId.values()];
}
}

View File

@ -0,0 +1,93 @@
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialSerializer } from './credential.serializer';
import type { WorkflowCredentialRequirement } from './credential.types';
import type { PackageWriter } from '../../io/package-writer';
import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator';
import type { ManifestEntry } from '../../spec/manifest.schema';
import type { PackageCredentialRequirement } from '../../spec/requirements.schema';
interface CredentialGroup {
// Name+type to use if the DB lookup fails — sourced from the workflow snapshot.
fallback: WorkflowCredentialRequirement;
usedByWorkflows: string[];
}
export interface CredentialExportRequest {
user: User;
requirements: WorkflowCredentialRequirement[];
writer: PackageWriter;
}
export interface CredentialExportResult {
entries: ManifestEntry[];
requirements: PackageCredentialRequirement[];
}
@Service()
export class CredentialExporter {
constructor(
private readonly credentialsFinder: CredentialsFinderService,
private readonly credentialSerializer: CredentialSerializer,
) {}
async export(request: CredentialExportRequest): Promise<CredentialExportResult> {
const allocator = new UniqueFilenameAllocator('credentials', 'credential');
const entries: ManifestEntry[] = [];
const requirements: PackageCredentialRequirement[] = [];
for (const [credentialId, { fallback, usedByWorkflows }] of this.groupByCredentialId(
request.requirements,
)) {
const credential = await this.credentialsFinder.findCredentialForUser(
credentialId,
request.user,
['credential:read'],
);
// Use the workflow data if we can't fetch the full credential
const { id, name, type } = credential ?? {
id: credentialId,
name: fallback.credentialName,
type: fallback.credentialType,
};
if (credential) {
const target = allocator.allocate(name);
request.writer.writeDirectory(target);
request.writer.writeFile(
`${target}/credential.json`,
JSON.stringify(this.credentialSerializer.serialize(credential), null, '\t'),
);
entries.push({ id, name, target });
}
requirements.push({ id, name, type, usedByWorkflows });
}
return { entries, requirements };
}
private groupByCredentialId(
requirements: WorkflowCredentialRequirement[],
): Map<string, CredentialGroup> {
const grouped = new Map<string, CredentialGroup>();
for (const requirement of requirements) {
const existing = grouped.get(requirement.credentialId);
if (existing) {
if (!existing.usedByWorkflows.includes(requirement.workflowId)) {
existing.usedByWorkflows.push(requirement.workflowId);
}
} else {
grouped.set(requirement.credentialId, {
fallback: requirement,
usedByWorkflows: [requirement.workflowId],
});
}
}
return grouped;
}
}

View File

@ -0,0 +1,18 @@
import type { CredentialsEntity } from '@n8n/db';
import { Service } from '@n8n/di';
import {
serializedCredentialSchema,
type SerializedCredential,
} from '../../spec/serialized/credential.schema';
@Service()
export class CredentialSerializer {
serialize(credential: CredentialsEntity): SerializedCredential {
return serializedCredentialSchema.parse({
id: credential.id,
name: credential.name,
type: credential.type,
});
}
}

View File

@ -0,0 +1,6 @@
export interface WorkflowCredentialRequirement {
workflowId: string;
credentialId: string;
credentialName: string;
credentialType: string;
}

View File

@ -0,0 +1,5 @@
import type { WorkflowEntity } from '@n8n/db';
export interface RequirementsExtractor<TRequirement> {
extract(workflow: WorkflowEntity): TRequirement[];
}

View File

@ -1,10 +1,11 @@
import type { User, WorkflowEntity } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import type { Readable } from 'node:stream';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { PackageWriter } from '../../../io/package-writer';
import { CapturingWriter } from '../../../io/__tests__/utils/capturing-writer';
import { CredentialRequirementsExtractor } from '../../credential/credential-requirements.extractor';
import type { WorkflowCredentialRequirement } from '../../credential/credential.types';
import { WorkflowExporter } from '../workflow.exporter';
import { WorkflowSerializer } from '../workflow.serializer';
@ -25,28 +26,14 @@ function makeWorkflow(overrides: Partial<WorkflowEntity> = {}): WorkflowEntity {
} as unknown as WorkflowEntity;
}
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(returned: WorkflowEntity[]) {
function makeExporter(returned: WorkflowEntity[], extractor?: CredentialRequirementsExtractor) {
const finder = mock<WorkflowFinderService>();
finder.findWorkflowsByIdsForUser.mockResolvedValue(returned);
const exporter = new WorkflowExporter(finder, new WorkflowSerializer());
const exporter = new WorkflowExporter(
finder,
new WorkflowSerializer(),
extractor ?? new CredentialRequirementsExtractor(),
);
return { exporter, finder };
}
@ -87,7 +74,7 @@ describe('WorkflowExporter', () => {
const { exporter } = makeExporter([workflow]);
const writer = new CapturingWriter();
const entries = await exporter.export({
const { entries } = await exporter.export({
user,
workflowIds: [workflow.id, workflow.id],
writer,
@ -107,7 +94,7 @@ describe('WorkflowExporter', () => {
const { exporter } = makeExporter([a, b]);
const writer = new CapturingWriter();
const entries = await exporter.export({ user, workflowIds: [a.id, b.id], writer });
const { entries } = await exporter.export({ user, workflowIds: [a.id, b.id], writer });
const targets = entries.map((e) => e.target);
expect(targets).toEqual(['workflows/same-name', 'workflows/same-name-2']);
@ -116,4 +103,44 @@ describe('WorkflowExporter', () => {
expect(writtenPaths).toContain('workflows/same-name/workflow.json');
expect(writtenPaths).toContain('workflows/same-name-2/workflow.json');
});
it('runs the extractor on each workflow and concatenates the results into requirements.credentials', async () => {
// Per-workflow extraction logic lives in CredentialRequirementsExtractor's
// own suite; this test only proves the exporter wires the extractor in.
const a = makeWorkflow({ id: 'wf-a' });
const b = makeWorkflow({ id: 'wf-b' });
const extractor = mock<CredentialRequirementsExtractor>();
extractor.extract.mockImplementation((workflow) => [
{
workflowId: workflow.id,
credentialId: `cred-from-${workflow.id}`,
credentialName: workflow.id,
credentialType: 'httpHeaderAuth',
},
]);
const { exporter } = makeExporter([a, b], extractor);
const writer = new CapturingWriter();
const { requirements } = await exporter.export({
user,
workflowIds: [a.id, b.id],
writer,
});
expect(extractor.extract).toHaveBeenCalledTimes(2);
expect(requirements.credentials).toEqual<WorkflowCredentialRequirement[]>([
{
workflowId: 'wf-a',
credentialId: 'cred-from-wf-a',
credentialName: 'wf-a',
credentialType: 'httpHeaderAuth',
},
{
workflowId: 'wf-b',
credentialId: 'cred-from-wf-b',
credentialName: 'wf-b',
credentialType: 'httpHeaderAuth',
},
]);
});
});

View File

@ -6,8 +6,10 @@ import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowSerializer } from './workflow.serializer';
import type { PackageWriter } from '../../io/package-writer';
import { generateSlug } from '../../io/slug.utils';
import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator';
import type { ManifestEntry } from '../../spec/manifest.schema';
import { CredentialRequirementsExtractor } from '../credential/credential-requirements.extractor';
import type { WorkflowCredentialRequirement } from '../credential/credential.types';
export interface WorkflowExportRequest {
user: User;
@ -15,14 +17,24 @@ export interface WorkflowExportRequest {
writer: PackageWriter;
}
export interface WorkflowExportRequirements {
credentials: WorkflowCredentialRequirement[];
}
export interface WorkflowExportResult {
entries: ManifestEntry[];
requirements: WorkflowExportRequirements;
}
@Service()
export class WorkflowExporter {
constructor(
private readonly workflowFinder: WorkflowFinderService,
private readonly workflowSerializer: WorkflowSerializer,
private readonly credentialRequirementsExtractor: CredentialRequirementsExtractor,
) {}
async export(request: WorkflowExportRequest): Promise<ManifestEntry[]> {
async export(request: WorkflowExportRequest): Promise<WorkflowExportResult> {
const workflows = await this.workflowFinder.findWorkflowsByIdsForUser(
request.workflowIds,
request.user,
@ -33,10 +45,11 @@ export class WorkflowExporter {
this.assertAllRequestedWorkflowsFound(request.workflowIds, workflows);
const entries: ManifestEntry[] = [];
const usedTargets = new Set<string>();
const credentials: WorkflowCredentialRequirement[] = [];
const fileNames = new UniqueFilenameAllocator('workflows');
for (const workflow of workflows) {
const target = this.allocateUniqueFileName(workflow.name, usedTargets);
const target = fileNames.allocate(workflow.name);
const serialized = this.workflowSerializer.serialize(workflow);
request.writer.writeDirectory(target);
@ -47,26 +60,11 @@ export class WorkflowExporter {
name: workflow.name,
target,
});
credentials.push(...this.credentialRequirementsExtractor.extract(workflow));
}
return entries;
}
private allocateUniqueFileName(name: string, used: Set<string>): string {
const base = `workflows/${generateSlug(name)}`;
if (!used.has(base)) {
used.add(base);
return base;
}
for (let suffix = 2; ; suffix++) {
const candidate = `${base}-${suffix}`;
if (!used.has(candidate)) {
used.add(candidate);
return candidate;
}
}
return { entries, requirements: { credentials } };
}
private assertAllRequestedWorkflowsFound(

View File

@ -17,6 +17,11 @@ describe('generateSlug', () => {
expect(generateSlug('--')).toBe('workflow');
});
it('uses a caller-provided fallback when the name yields an empty slug', () => {
expect(generateSlug('--', 'credential')).toBe('credential');
expect(generateSlug('', 'credential')).toBe('credential');
});
it('strips leading and trailing hyphens', () => {
expect(generateSlug('---foo---')).toBe('foo');
});

View File

@ -0,0 +1,48 @@
import { UniqueFilenameAllocator } from '../unique-filename-allocator';
describe('UniqueFilenameAllocator', () => {
it('returns the slugged base path on first allocation', () => {
const allocator = new UniqueFilenameAllocator('workflows');
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');
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');
allocator.allocate('Same Name');
allocator.allocate('Same Name');
expect(allocator.allocate('Same Name')).toBe('workflows/same-name-3');
});
it('uses the supplied fallback when the name slugifies to an empty string', () => {
const allocator = new UniqueFilenameAllocator('credentials', 'credential');
expect(allocator.allocate('!!!')).toBe('credentials/credential');
});
it('suffixes the fallback when two unslugifiable names collide', () => {
// Two credentials named entirely with stripped characters both
// fall back to the same slug; the second one must still get a
// unique target rather than overwriting the first.
const allocator = new UniqueFilenameAllocator('credentials', 'credential');
expect(allocator.allocate('!!!')).toBe('credentials/credential');
expect(allocator.allocate('???')).toBe('credentials/credential-2');
});
it('keeps the per-instance used set independent across allocators', () => {
const a = new UniqueFilenameAllocator('workflows');
const b = new UniqueFilenameAllocator('workflows');
expect(a.allocate('Same Name')).toBe('workflows/same-name');
expect(b.allocate('Same Name')).toBe('workflows/same-name');
});
});

View File

@ -0,0 +1,21 @@
import type { Readable } from 'node:stream';
import type { PackageWriter } from '../../package-writer';
export 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('CapturingWriter is for unit tests and does not produce a stream');
}
}

View File

@ -1,9 +1,9 @@
const EMPTY_SLUG_FALLBACK = 'workflow';
const DEFAULT_EMPTY_SLUG_FALLBACK = 'workflow';
/**
* Generates a filesystem-safe slug from an entity name
*/
export function generateSlug(name: string): string {
export function generateSlug(name: string, fallback: string = DEFAULT_EMPTY_SLUG_FALLBACK): string {
let slug = name;
slug = slug
.toLowerCase()
@ -18,5 +18,5 @@ export function generateSlug(name: string): string {
// Remove any - at the start or end of the slug
.replace(/^-|-$/g, '');
return slug || EMPTY_SLUG_FALLBACK;
return slug || fallback;
}

View File

@ -0,0 +1,27 @@
import { generateSlug } from './slug.utils';
export class UniqueFilenameAllocator {
private readonly used = new Set<string>();
constructor(
private readonly baseDir: string,
private readonly fallback?: string,
) {}
allocate(name: string): string {
const base = `${this.baseDir}/${generateSlug(name, this.fallback)}`;
if (!this.used.has(base)) {
this.used.add(base);
return base;
}
for (let suffix = 2; ; suffix++) {
const candidate = `${base}-${suffix}`;
if (!this.used.has(candidate)) {
this.used.add(candidate);
return candidate;
}
}
}
}

View File

@ -4,6 +4,7 @@ import type { Readable } from 'node:stream';
import { N8N_VERSION } from '@/constants';
import { CredentialExporter } from './entities/credential/credential.exporter';
import { WorkflowExporter } from './entities/workflow/workflow.exporter';
import { ImportPipeline } from './engine/import-pipeline';
import { TarPackageWriter } from './io/tar/tar-package-writer';
@ -19,6 +20,7 @@ import { packageManifestSchema } from './spec/manifest.schema';
export class N8nPackagesService {
constructor(
private readonly workflowExporter: WorkflowExporter,
private readonly credentialExporter: CredentialExporter,
private readonly instanceSettings: InstanceSettings,
private readonly importPipeline: ImportPipeline,
) {}
@ -26,11 +28,19 @@ export class N8nPackagesService {
async exportWorkflows(request: ExportWorkflowsRequest): Promise<Readable> {
const writer = new TarPackageWriter();
const workflowEntries = await this.workflowExporter.export({
user: request.user,
workflowIds: request.workflowIds,
writer,
});
const { entries: workflowEntries, requirements: workflowRequirements } =
await this.workflowExporter.export({
user: request.user,
workflowIds: request.workflowIds,
writer,
});
const { entries: credentialEntries, requirements: credentialRequirements } =
await this.credentialExporter.export({
user: request.user,
requirements: workflowRequirements.credentials,
writer,
});
const manifest = packageManifestSchema.parse({
packageFormatVersion: FORMAT_VERSION,
@ -38,6 +48,10 @@ export class N8nPackagesService {
sourceN8nVersion: N8N_VERSION,
sourceId: this.instanceSettings.instanceId,
workflows: workflowEntries,
...(credentialEntries.length > 0 ? { credentials: credentialEntries } : {}),
...(credentialRequirements.length > 0
? { requirements: { credentials: credentialRequirements } }
: {}),
});
writer.writeFile('manifest.json', JSON.stringify(manifest, null, '\t'));

View File

@ -2,6 +2,8 @@ import { z } from 'zod';
import { FORMAT_VERSION } from './constants';
import { packageRequirementsSchema } from './requirements.schema';
export const manifestEntrySchema = z.object({
id: z.string().min(1),
name: z.string(),
@ -15,6 +17,8 @@ export const packageManifestSchema = z
sourceN8nVersion: z.string().min(1),
sourceId: z.string().min(1),
workflows: z.array(manifestEntrySchema).optional(),
credentials: z.array(manifestEntrySchema).optional(),
requirements: packageRequirementsSchema.optional(),
})
.superRefine((manifest, ctx) => {
if (!manifest.workflows) return;

View File

@ -0,0 +1,15 @@
import { z } from 'zod';
export const packageCredentialRequirementSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
type: z.string().min(1),
usedByWorkflows: z.array(z.string().min(1)).min(1),
});
export const packageRequirementsSchema = z.object({
credentials: z.array(packageCredentialRequirementSchema).optional(),
});
export type PackageCredentialRequirement = z.infer<typeof packageCredentialRequirementSchema>;
export type PackageRequirements = z.infer<typeof packageRequirementsSchema>;

View File

@ -0,0 +1,11 @@
import { z } from 'zod';
export const serializedCredentialSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
type: z.string().min(1),
})
.strict();
export type SerializedCredential = z.infer<typeof serializedCredentialSchema>;