mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-02 17:57:06 +02:00
feat(core): Workflow export with credentials (#31241)
This commit is contained in:
parent
3dfca93a37
commit
00431d7505
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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()];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export interface WorkflowCredentialRequirement {
|
||||
workflowId: string;
|
||||
credentialId: string;
|
||||
credentialName: string;
|
||||
credentialType: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import type { WorkflowEntity } from '@n8n/db';
|
||||
|
||||
export interface RequirementsExtractor<TRequirement> {
|
||||
extract(workflow: WorkflowEntity): TRequirement[];
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -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>;
|
||||
Loading…
Reference in New Issue
Block a user