mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
fix(core): Handle managed credentials in workflow codegen (#36210)
This commit is contained in:
@@ -30,7 +30,7 @@ interface NormalizedNode {
|
||||
typeVersion: number;
|
||||
position: [number, number];
|
||||
parameters: Record<string, unknown>;
|
||||
credentials?: Record<string, { id?: string; name: string }>;
|
||||
credentials?: NodeRaw['credentials'];
|
||||
webhookId?: string;
|
||||
disabled?: boolean;
|
||||
notes?: string;
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import type { WorkflowJSON } from '@n8n/workflow-sdk';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { executeTool } from '../../__tests__/tool-test-utils';
|
||||
import type { InstanceAiContext } from '../../types';
|
||||
import {
|
||||
getWorkflowSourceFileBinding,
|
||||
saveWorkflowSourceFileBinding,
|
||||
} from '../workflows/workflow-file-bindings';
|
||||
import { createWorkflowsTool } from '../workflows.tool';
|
||||
|
||||
interface GetAsCodeResult {
|
||||
workflowId: string;
|
||||
name: string;
|
||||
code: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function makeManagedWorkflow(): WorkflowJSON {
|
||||
return {
|
||||
id: 'wf-managed',
|
||||
name: 'Managed credential workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'slack-1',
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2.2,
|
||||
position: [0, 0],
|
||||
parameters: { channel: '#alerts' },
|
||||
credentials: {
|
||||
slackApi: { id: null, name: 'n8n credits', __aiGatewayManaged: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(workflow: WorkflowJSON): InstanceAiContext {
|
||||
const context = mock<InstanceAiContext>();
|
||||
context.threadId = undefined;
|
||||
context.threadMemory = undefined;
|
||||
context.workflowService.getAsWorkflowJSON = vi.fn().mockResolvedValue(workflow);
|
||||
context.workflowService.get = vi.fn().mockResolvedValue({
|
||||
id: 'wf-managed',
|
||||
name: 'Managed credential workflow',
|
||||
versionId: 'v-current',
|
||||
checksum: 'checksum-current',
|
||||
activeVersionId: null,
|
||||
isArchived: false,
|
||||
createdAt: '2026-08-13T00:00:00.000Z',
|
||||
updatedAt: '2026-08-13T00:00:00.000Z',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
});
|
||||
return context;
|
||||
}
|
||||
|
||||
describe('workflows get-as-code integration', () => {
|
||||
it('returns real TypeScript for a managed credential and refreshes its binding', async () => {
|
||||
const context = makeContext(makeManagedWorkflow());
|
||||
const filePath = 'src/workflows/managed.workflow.ts';
|
||||
await saveWorkflowSourceFileBinding(context, {
|
||||
filePath,
|
||||
workflowId: 'wf-managed',
|
||||
workflowVersionId: 'v-stale',
|
||||
workflowChecksum: 'checksum-stale',
|
||||
});
|
||||
const tool = createWorkflowsTool(context, 'full');
|
||||
|
||||
const result = await executeTool<GetAsCodeResult>(tool, {
|
||||
action: 'get-as-code',
|
||||
workflowId: 'wf-managed',
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.code).not.toBe('');
|
||||
expect(result.code).toContain("newCredential('n8n credits')");
|
||||
expect(result.code).not.toContain("newCredential('n8n credits',");
|
||||
await expect(getWorkflowSourceFileBinding(context, filePath)).resolves.toMatchObject({
|
||||
workflowVersionId: 'v-current',
|
||||
workflowChecksum: 'checksum-current',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,14 @@ function makeCredentialMap(credentials: CredentialEntry[]): CredentialMap {
|
||||
return map;
|
||||
}
|
||||
|
||||
function makeManagedCredential(): {
|
||||
id: null;
|
||||
name: string;
|
||||
__aiGatewayManaged: true;
|
||||
} {
|
||||
return { id: null, name: 'n8n credits', __aiGatewayManaged: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -634,6 +642,46 @@ describe('resolveCredentials', () => {
|
||||
slackApi: { id: 'existing-id', name: 'Existing Slack' },
|
||||
});
|
||||
});
|
||||
|
||||
it('restores a saved managed credential from a null placeholder', async () => {
|
||||
const managedCredential = makeManagedCredential();
|
||||
const workflow = makeWorkflow({
|
||||
nodes: [
|
||||
{
|
||||
id: 'slack-1',
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2.2,
|
||||
position: [0, 0],
|
||||
credentials: {
|
||||
slackApi: null as unknown as { id: string; name: string },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const existingWorkflow = makeWorkflow({
|
||||
nodes: [
|
||||
{
|
||||
id: 'slack-1',
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2.2,
|
||||
position: [0, 0],
|
||||
credentials: { slackApi: managedCredential },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await resolveCredentials(
|
||||
workflow,
|
||||
'wf-123',
|
||||
createMockContext(existingWorkflow),
|
||||
makeCredentialMap([]),
|
||||
);
|
||||
|
||||
expect(result.mockedNodeNames).toEqual([]);
|
||||
expect(workflow.nodes[0]?.credentials?.slackApi).toBe(managedCredential);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sibling node credential reuse', () => {
|
||||
|
||||
@@ -138,6 +138,37 @@ describe('generateWorkflowCode', () => {
|
||||
expect(code).toContain("slackApi: newCredential('My Slack', 'cred-123')");
|
||||
});
|
||||
|
||||
it('should generate a placeholder for an AI Gateway managed credential', () => {
|
||||
const json: WorkflowJSON = {
|
||||
id: 'managed-credential-test',
|
||||
name: 'Managed Credentials Test',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node-1',
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 2.2,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
credentials: {
|
||||
slackApi: {
|
||||
id: null,
|
||||
name: 'n8n Connect',
|
||||
__aiGatewayManaged: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
};
|
||||
|
||||
const code = generateWorkflowCode(json);
|
||||
|
||||
expect(code).toContain("slackApi: newCredential('n8n Connect')");
|
||||
expect(code).not.toContain('id: null');
|
||||
expect(code).not.toContain('__aiGatewayManaged');
|
||||
});
|
||||
|
||||
it('should generate code for sticky notes', () => {
|
||||
const json: WorkflowJSON = {
|
||||
id: 'sticky-test',
|
||||
@@ -427,6 +458,13 @@ describe('generateWorkflowCode with AI subnodes', () => {
|
||||
parameters: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
credentials: {
|
||||
openAiApi: {
|
||||
id: null,
|
||||
name: 'n8n Connect',
|
||||
__aiGatewayManaged: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
@@ -450,6 +488,9 @@ describe('generateWorkflowCode with AI subnodes', () => {
|
||||
// Should reference the variable in subnodes config
|
||||
expect(code).toContain('subnodes:');
|
||||
expect(code).toMatch(/model: \w+/); // Variable reference, not inline call
|
||||
expect(code).toContain("openAiApi: newCredential('n8n Connect')");
|
||||
expect(code).not.toContain('id: null');
|
||||
expect(code).not.toContain('__aiGatewayManaged');
|
||||
});
|
||||
|
||||
it('should generate subnode config for AI agent with multiple subnodes', () => {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
import { generateSubnodeCall, generateSubnodesConfig, formatValue } from './subnode-generator';
|
||||
import {
|
||||
formatCredentials,
|
||||
generateSubnodeCall,
|
||||
generateSubnodesConfig,
|
||||
formatValue,
|
||||
} from './subnode-generator';
|
||||
import type { SemanticGraph, SemanticNode, AiConnectionType } from './types';
|
||||
|
||||
/**
|
||||
@@ -239,6 +244,32 @@ describe('generateSubnodesConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCredentials', () => {
|
||||
it('preserves a bare null credential ID as a literal', () => {
|
||||
const result = formatCredentials({
|
||||
openAiApi: { id: null, name: 'OpenAI API' },
|
||||
});
|
||||
|
||||
expect(result).toBe("{ openAiApi: { id: null, name: 'OpenAI API' } }");
|
||||
});
|
||||
|
||||
it('keeps string credential IDs in the existing two-argument form', () => {
|
||||
const result = formatCredentials({
|
||||
slackApi: { id: 'cred-123', name: 'My Slack' },
|
||||
});
|
||||
|
||||
expect(result).toBe("{ slackApi: newCredential('My Slack', 'cred-123') }");
|
||||
});
|
||||
|
||||
it('keeps name-only credentials in the existing one-argument form', () => {
|
||||
const result = formatCredentials({
|
||||
slackApi: { name: 'My Slack' },
|
||||
});
|
||||
|
||||
expect(result).toBe("{ slackApi: newCredential('My Slack') }");
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatValue', () => {
|
||||
it('formats placeholder values as placeholder() function calls', () => {
|
||||
const placeholderString = '<__PLACEHOLDER_VALUE__Enter Slack Channel__>';
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* Supports both inline generation and variable reference modes.
|
||||
*/
|
||||
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
|
||||
import {
|
||||
AI_CONNECTION_TO_CONFIG_KEY,
|
||||
AI_CONNECTION_TO_BUILDER,
|
||||
@@ -25,29 +27,41 @@ import type { SemanticGraph, SemanticNode, AiConnectionType } from './types';
|
||||
* Emits `newCredential('name', 'id')` for credentials with an id,
|
||||
* or `newCredential('name')` for placeholder credentials.
|
||||
*/
|
||||
export function formatCredentials(
|
||||
credentials: Record<string, { id?: string; name?: string }>,
|
||||
): string {
|
||||
export function formatCredentials(credentials: unknown): string {
|
||||
// Guard: some workflows have credentials as a string (e.g. "[REDACTED]")
|
||||
// instead of the expected Record<string, {id?, name?}>.
|
||||
if (typeof credentials === 'string') {
|
||||
return `'${escapeString(credentials)}'`;
|
||||
}
|
||||
if (!isRecord(credentials)) {
|
||||
return formatValue(credentials);
|
||||
}
|
||||
const entries = Object.entries(credentials).map(([key, value]) => {
|
||||
// If credential has a name, use newCredential() call
|
||||
if (value.name !== undefined || value.id !== undefined) {
|
||||
if (value.name !== undefined) {
|
||||
if (value.id !== undefined) {
|
||||
return `${formatKey(key)}: newCredential('${escapeString(value.name)}', '${escapeString(value.id)}')`;
|
||||
}
|
||||
return `${formatKey(key)}: newCredential('${escapeString(value.name)}')`;
|
||||
if (!isRecord(value)) {
|
||||
return `${formatKey(key)}: ${formatValue(value)}`;
|
||||
}
|
||||
|
||||
const name = value.name;
|
||||
const id = value.id;
|
||||
|
||||
if (typeof name === 'string') {
|
||||
// Managed credentials have no persisted ID, so emit the placeholder form.
|
||||
if (id === null && value.__aiGatewayManaged === true) {
|
||||
return `${formatKey(key)}: newCredential('${escapeString(name)}')`;
|
||||
}
|
||||
// id-only credential (no name property) — emit raw object to preserve shape
|
||||
if (value.id !== undefined) {
|
||||
return `${formatKey(key)}: { id: '${escapeString(value.id)}' }`;
|
||||
if (id === undefined) {
|
||||
return `${formatKey(key)}: newCredential('${escapeString(name)}')`;
|
||||
}
|
||||
if (typeof id === 'string') {
|
||||
return `${formatKey(key)}: newCredential('${escapeString(name)}', '${escapeString(id)}')`;
|
||||
}
|
||||
}
|
||||
// Empty credential object — preserve as-is
|
||||
|
||||
// id-only credential (no name property) — emit raw object to preserve shape
|
||||
if (name === undefined && typeof id === 'string') {
|
||||
return `${formatKey(key)}: { id: '${escapeString(id)}' }`;
|
||||
}
|
||||
// Empty or malformed credential object — preserve as-is
|
||||
return `${formatKey(key)}: ${formatValue(value)}`;
|
||||
});
|
||||
return `{ ${entries.join(', ')} }`;
|
||||
|
||||
@@ -296,6 +296,11 @@ export function generateUniqueName(baseName: string, exists: (name: string) => b
|
||||
// Internal: Serialization types
|
||||
// =============================================================================
|
||||
|
||||
/** Persisted managed references use a null ID with the managed marker. */
|
||||
type NodeJSONCredential =
|
||||
| { id?: string; name: string; __aiGatewayManaged?: boolean }
|
||||
| { id: null; name: string; __aiGatewayManaged?: boolean };
|
||||
|
||||
/**
|
||||
* Node JSON representation (for serialization)
|
||||
*/
|
||||
@@ -306,7 +311,7 @@ export interface NodeJSON {
|
||||
typeVersion: number;
|
||||
position: [number, number];
|
||||
parameters?: IDataObject;
|
||||
credentials?: Record<string, { id?: string; name: string }>;
|
||||
credentials?: Record<string, NodeJSONCredential>;
|
||||
webhookId?: string;
|
||||
disabled?: boolean;
|
||||
notes?: string;
|
||||
|
||||
@@ -45,6 +45,7 @@ vi.mock('@n8n/ai-utilities', () => ({
|
||||
}));
|
||||
|
||||
import { Container } from '@n8n/di';
|
||||
import { generateWorkflowCode } from '@n8n/workflow-sdk';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { Expression } from 'n8n-workflow';
|
||||
import type {
|
||||
@@ -2123,6 +2124,64 @@ describe('createWorkflowAdapter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('returns AI Gateway-managed credentials in a shape accepted by workflow codegen', async () => {
|
||||
const { adapter, mockWorkflowFinderService } = createWorkflowAdapterForTests();
|
||||
mockWorkflowFinderService.findWorkflowForUser.mockResolvedValue({
|
||||
id: 'wf-managed',
|
||||
name: 'Managed model workflow',
|
||||
active: false,
|
||||
versionId: 'version-id',
|
||||
activeVersionId: null,
|
||||
isArchived: false,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
nodes: [
|
||||
{
|
||||
id: 'agent-id',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 3.1,
|
||||
position: [0, 0],
|
||||
parameters: { promptType: 'define', text: 'Summarize the input.' },
|
||||
},
|
||||
{
|
||||
id: 'model-id',
|
||||
name: 'Google Gemini Chat Model',
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatGoogleGemini',
|
||||
typeVersion: 1.1,
|
||||
position: [0, 200],
|
||||
parameters: { modelName: 'models/gemini-3-flash-preview' },
|
||||
credentials: {
|
||||
googlePalmApi: {
|
||||
id: null,
|
||||
name: 'n8n credits',
|
||||
__aiGatewayManaged: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Google Gemini Chat Model': {
|
||||
ai_languageModel: [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]],
|
||||
},
|
||||
},
|
||||
settings: {},
|
||||
});
|
||||
|
||||
const workflow = await adapter.getAsWorkflowJSON('wf-managed');
|
||||
|
||||
expect(workflow.nodes[1].credentials).toEqual({
|
||||
googlePalmApi: {
|
||||
id: null,
|
||||
name: 'n8n credits',
|
||||
__aiGatewayManaged: true,
|
||||
},
|
||||
});
|
||||
const code = generateWorkflowCode(workflow);
|
||||
expect(code).toContain("newCredential('n8n credits')");
|
||||
expect(code).not.toContain("newCredential('n8n credits', 'null')");
|
||||
});
|
||||
|
||||
it('returns the version graph with current workflow metadata when a versionId is passed', async () => {
|
||||
const { adapter, mockWorkflowHistoryService, mockUser } = createWorkflowAdapterForTests();
|
||||
mockWorkflowHistoryService.getVersion.mockResolvedValue({
|
||||
|
||||
@@ -3951,7 +3951,7 @@ function toWorkflowJSON(
|
||||
typeVersion: n.typeVersion,
|
||||
position: n.position,
|
||||
parameters: redact ? {} : n.parameters,
|
||||
credentials: n.credentials as Record<string, { id?: string; name: string }> | undefined,
|
||||
credentials: n.credentials,
|
||||
webhookId: n.webhookId,
|
||||
disabled: n.disabled,
|
||||
notes: n.notes,
|
||||
|
||||
Reference in New Issue
Block a user