feat(editor): Validate private credentials only run under manual triggers (#31211)

This commit is contained in:
Andreas Fitzek 2026-06-01 10:19:41 +02:00 committed by GitHub
parent be3241dc22
commit 5de0d32e2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 469 additions and 109 deletions

View File

@ -3727,6 +3727,7 @@
"nodeIssues.credentials.doNotExist.hint": "You can create credentials with the exact name and then they get auto-selected on refresh..",
"nodeIssues.credentials.notIdentified": "Credentials with name {name} exist for {type}.",
"nodeIssues.credentials.notIdentified.hint": "Credentials are not clearly identified. Please select the correct credentials.",
"nodeIssues.credentials.privateRequiresManualTrigger": "Private credentials only work in manually triggered workflows. Change the trigger to a Manual trigger, or switch this credential to Static.",
"nodeIssues.input.missing": "No node connected to required input \"{inputName}\"",
"ndv.trigger.moreInfo": "More info",
"ndv.trigger.copiedTestUrl": "Test URL copied to clipboard",

View File

@ -434,6 +434,41 @@ describe('useCanvasOperations', () => {
);
});
});
it('re-evaluates all credential issues when the added node is a trigger', async () => {
const nodeTypesStore = mockedStore(useNodeTypesStore);
nodeTypesStore.isTriggerNode = vi.fn().mockReturnValue(true);
const updateNodesCredentialsIssuesSpy = vi.fn();
const nodeHelpersOriginal = nodeHelpers.useNodeHelpers();
vi.spyOn(nodeHelpers, 'useNodeHelpers').mockImplementation(() => ({
...nodeHelpersOriginal,
updateNodesCredentialsIssues: updateNodesCredentialsIssuesSpy,
}));
const { addNode } = useCanvasOperations();
addNode({ type: 'trigger', typeVersion: 1 }, mockNodeTypeDescription({ name: 'trigger' }));
await waitFor(() => expect(updateNodesCredentialsIssuesSpy).toHaveBeenCalled());
});
it('does not re-evaluate all credential issues when the added node is not a trigger', async () => {
const nodeTypesStore = mockedStore(useNodeTypesStore);
nodeTypesStore.isTriggerNode = vi.fn().mockReturnValue(false);
const updateNodesCredentialsIssuesSpy = vi.fn();
const nodeHelpersOriginal = nodeHelpers.useNodeHelpers();
vi.spyOn(nodeHelpers, 'useNodeHelpers').mockImplementation(() => ({
...nodeHelpersOriginal,
updateNodesCredentialsIssues: updateNodesCredentialsIssuesSpy,
}));
const { addNode } = useCanvasOperations();
addNode({ type: 'set', typeVersion: 1 }, mockNodeTypeDescription({ name: 'set' }));
await nextTick();
expect(updateNodesCredentialsIssuesSpy).not.toHaveBeenCalled();
});
});
describe('resolveNodePosition', () => {
@ -1452,6 +1487,48 @@ describe('useCanvasOperations', () => {
);
});
it('re-evaluates all credential issues when the deleted node is a trigger', () => {
const nodeTypesStore = mockedStore(useNodeTypesStore);
nodeTypesStore.isTriggerNode = vi.fn().mockReturnValue(true);
vi.mocked(workflowDocumentStoreInstance.incomingConnectionsByNodeName).mockReturnValue({});
const node = createTestNode({ id: 'trigger-1', type: 'trigger', name: 'Trigger' });
vi.spyOn(workflowDocumentStoreInstance, 'getNodeById').mockReturnValue(node);
const updateNodesCredentialsIssuesSpy = vi.fn();
const nodeHelpersOriginal = nodeHelpers.useNodeHelpers();
vi.spyOn(nodeHelpers, 'useNodeHelpers').mockImplementation(() => ({
...nodeHelpersOriginal,
updateNodesCredentialsIssues: updateNodesCredentialsIssuesSpy,
}));
const { deleteNode } = useCanvasOperations();
deleteNode(node.id);
expect(updateNodesCredentialsIssuesSpy).toHaveBeenCalled();
});
it('does not re-evaluate all credential issues when the deleted node is not a trigger', () => {
const nodeTypesStore = mockedStore(useNodeTypesStore);
nodeTypesStore.isTriggerNode = vi.fn().mockReturnValue(false);
vi.mocked(workflowDocumentStoreInstance.incomingConnectionsByNodeName).mockReturnValue({});
const node = createTestNode({ id: 'set-1', type: 'set', name: 'Set' });
vi.spyOn(workflowDocumentStoreInstance, 'getNodeById').mockReturnValue(node);
const updateNodesCredentialsIssuesSpy = vi.fn();
const nodeHelpersOriginal = nodeHelpers.useNodeHelpers();
vi.spyOn(nodeHelpers, 'useNodeHelpers').mockImplementation(() => ({
...nodeHelpersOriginal,
updateNodesCredentialsIssues: updateNodesCredentialsIssuesSpy,
}));
const { deleteNode } = useCanvasOperations();
deleteNode(node.id);
expect(updateNodesCredentialsIssuesSpy).not.toHaveBeenCalled();
});
it('should delete node without tracking history', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const historyStore = mockedStore(useHistoryStore);

View File

@ -485,6 +485,8 @@ export function useCanvasOperations() {
return;
}
const wasTrigger = nodeTypesStore.isTriggerNode(node.type);
if (trackHistory && trackBulk) {
historyStore.startRecordingUndo();
}
@ -507,6 +509,12 @@ export function useCanvasOperations() {
}
}
// Removing a trigger node can flip private-credential validity on every
// other node — re-evaluate credential issues across the workflow.
if (wasTrigger) {
nodeHelpers.updateNodesCredentialsIssues();
}
trackDeleteNode(id);
}
@ -906,6 +914,12 @@ export function useCanvasOperations() {
nodeHelpers.updateNodeCredentialIssues(nodeData);
nodeHelpers.updateNodeInputIssues(nodeData);
// Adding a trigger node can flip private-credential validity on every
// other node — re-evaluate credential issues across the workflow.
if (nodeTypesStore.isTriggerNode(nodeData.type)) {
nodeHelpers.updateNodesCredentialsIssues();
}
const isStickyNode = nodeData.type === STICKY_NODE_TYPE;
const nextView =
isStickyNode || !options.openNDV || preventOpeningNDV

View File

@ -40,8 +40,8 @@ const mockDocumentStore = {
settings: {},
pinnedDataByNodeName: {},
usedCredentials: mockDocumentStoreUsedCredentials,
allNodes: [],
workflowTriggerNodes: [],
allNodes: [] as INodeUi[],
workflowTriggerNodes: [] as INodeUi[],
getNodeByName: vi.fn(),
setNodeIssue: vi.fn(),
updateNodeProperties: vi.fn(),
@ -803,7 +803,12 @@ describe('useNodeHelpers()', () => {
});
});
describe('credential issues for private credentials', () => {
describe('private credentials', () => {
const NOTION_API = 'notionApi';
const MANUAL_TRIGGER = 'n8n-nodes-base.manualTrigger';
const MANUAL_CHAT_TRIGGER = '@n8n/n8n-nodes-langchain.manualChatTrigger';
const WEBHOOK_TRIGGER = 'n8n-nodes-base.webhook';
const notionNodeType: INodeTypeDescription = {
displayName: 'Notion',
name: 'n8n-nodes-base.notion',
@ -813,7 +818,7 @@ describe('useNodeHelpers()', () => {
defaults: { name: 'Notion' },
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [{ name: 'notionApi', required: true }],
credentials: [{ name: NOTION_API, required: true }],
properties: [],
};
@ -834,136 +839,379 @@ describe('useNodeHelpers()', () => {
({
id: 'cred-123',
name: 'My Notion',
type: 'notionApi',
type: NOTION_API,
isResolvable: true,
connectedByMe: false,
...overrides,
}) as ICredentialsResponse;
const buildNotionNode = (): INodeUi =>
createTestNode({
type: 'n8n-nodes-base.notion',
credentials: { [NOTION_API]: { id: 'cred-123', name: 'My Notion' } },
});
const buildTriggerNode = (type: string, overrides: Partial<INodeUi> = {}): INodeUi =>
createTestNode({ type, ...overrides });
beforeEach(() => {
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue(notionNodeType);
});
it('emits privateNotConnected when declared-credential node has private cred not connected', () => {
const cred = makePrivateCred({ connectedByMe: false });
mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred);
mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]);
const node: INodeUi = createTestNode({
type: 'n8n-nodes-base.notion',
credentials: { notionApi: { id: 'cred-123', name: 'My Notion' } },
});
const { getNodeIssues } = useNodeHelpers();
const result = getNodeIssues(notionNodeType, node, mock<Workflow>(), ['parameters']);
expect(result?.credentials?.notionApi).toBeDefined();
expect(result?.credentials?.notionApi[0]).toContain('My Notion');
afterEach(() => {
mockDocumentStore.workflowTriggerNodes = [];
});
it('emits no issue when declared-credential node has private cred connected', () => {
const cred = makePrivateCred({ connectedByMe: true });
mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred);
mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]);
describe('not connected', () => {
it('emits privateNotConnected when declared-credential node has private cred not connected', () => {
const cred = makePrivateCred({ connectedByMe: false });
mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred);
mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]);
const node: INodeUi = createTestNode({
type: 'n8n-nodes-base.notion',
credentials: { notionApi: { id: 'cred-123', name: 'My Notion' } },
const { getNodeIssues } = useNodeHelpers();
const result = getNodeIssues(notionNodeType, buildNotionNode(), mock<Workflow>(), [
'parameters',
]);
expect(result?.credentials?.[NOTION_API]).toBeDefined();
expect(result?.credentials?.[NOTION_API][0]).toContain('My Notion');
});
const { getNodeIssues } = useNodeHelpers();
const result = getNodeIssues(notionNodeType, node, mock<Workflow>(), ['parameters']);
it('emits no issue when declared-credential node has private cred connected', () => {
const cred = makePrivateCred({ connectedByMe: true });
mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred);
mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]);
expect(result?.credentials).toBeUndefined();
const { getNodeIssues } = useNodeHelpers();
const result = getNodeIssues(notionNodeType, buildNotionNode(), mock<Workflow>(), [
'parameters',
]);
expect(result?.credentials).toBeUndefined();
});
it('emits privateNotConnected for predefined-OAuth credential (HTTP Request, not in node type credentials array)', () => {
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue(httpRequestNodeType);
const cred = makePrivateCred({ type: 'slackOAuth2Api', connectedByMe: false });
mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred);
const node: INodeUi = createTestNode({
type: 'n8n-nodes-base.httpRequest',
parameters: {
authentication: 'predefinedCredentialType',
nodeCredentialType: 'slackOAuth2Api',
},
credentials: { slackOAuth2Api: { id: 'cred-123', name: 'My Notion' } },
});
const { getNodeIssues } = useNodeHelpers();
const result = getNodeIssues(httpRequestNodeType, node, mock<Workflow>(), ['parameters']);
expect(result?.credentials?.slackOAuth2Api).toBeDefined();
expect(result?.credentials?.slackOAuth2Api[0]).toContain('My Notion');
});
it('emits no issue for static (non-resolvable) credential regardless of connectedByMe', () => {
const cred = makePrivateCred({ isResolvable: false, connectedByMe: false });
mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred);
mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]);
const { getNodeIssues } = useNodeHelpers();
const result = getNodeIssues(notionNodeType, buildNotionNode(), mock<Workflow>(), [
'parameters',
]);
expect(result?.credentials).toBeUndefined();
});
it('emits no issue for AI-gateway managed private credential', () => {
const cred = makePrivateCred({ connectedByMe: false });
mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred);
mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]);
const node: INodeUi = createTestNode({
type: 'n8n-nodes-base.notion',
credentials: {
[NOTION_API]: { id: 'cred-123', name: 'My Notion', __aiGatewayManaged: true },
},
});
const { getNodeIssues } = useNodeHelpers();
const result = getNodeIssues(notionNodeType, node, mock<Workflow>(), ['parameters']);
expect(result?.credentials).toBeUndefined();
});
it('preserves declared-loop notSet issue and does not overwrite with private check', () => {
mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([]);
const node: INodeUi = createTestNode({
type: 'n8n-nodes-base.notion',
credentials: {},
});
const { getNodeIssues } = useNodeHelpers();
const result = getNodeIssues(notionNodeType, node, mock<Workflow>(), ['parameters']);
expect(result?.credentials?.[NOTION_API]).toBeDefined();
expect(result?.credentials?.[NOTION_API][0]).toContain('Notion');
});
it('emits no issue when dynamic credentials feature is disabled', () => {
mockedUseDynamicCredentials.mockReturnValue({
isEnabled: computed(() => false),
} as ReturnType<typeof useDynamicCredentials>);
const cred = makePrivateCred({ connectedByMe: false });
mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred);
mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]);
const { getNodeIssues } = useNodeHelpers();
const result = getNodeIssues(notionNodeType, buildNotionNode(), mock<Workflow>(), [
'parameters',
]);
expect(result?.credentials?.[NOTION_API]).toBeUndefined();
});
});
it('emits privateNotConnected for predefined-OAuth credential (HTTP Request, not in node type credentials array)', () => {
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue(httpRequestNodeType);
describe('trigger compatibility', () => {
const mockConnectedPrivateCred = (isResolvable: boolean) => {
const cred = makePrivateCred({ isResolvable, connectedByMe: true });
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.getCredentialById = vi.fn().mockReturnValue(cred);
credentialsStore.getCredentialsByType = vi.fn().mockReturnValue([cred]);
credentialsStore.getCredentialTypeByName = vi
.fn()
.mockReturnValue({ name: NOTION_API, displayName: 'Notion API' });
};
const cred = makePrivateCred({ type: 'slackOAuth2Api', connectedByMe: false });
mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred);
it('does not warn when a private credential is used under a manual trigger', () => {
mockConnectedPrivateCred(true);
mockDocumentStore.workflowTriggerNodes = [buildTriggerNode(MANUAL_TRIGGER)];
const node: INodeUi = createTestNode({
type: 'n8n-nodes-base.httpRequest',
parameters: {
authentication: 'predefinedCredentialType',
nodeCredentialType: 'slackOAuth2Api',
},
credentials: { slackOAuth2Api: { id: 'cred-123', name: 'My Notion' } },
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result).toBeNull();
});
const { getNodeIssues } = useNodeHelpers();
const result = getNodeIssues(httpRequestNodeType, node, mock<Workflow>(), ['parameters']);
it('does not warn when a private credential is used under a manual chat trigger', () => {
mockConnectedPrivateCred(true);
mockDocumentStore.workflowTriggerNodes = [buildTriggerNode(MANUAL_CHAT_TRIGGER)];
expect(result?.credentials?.slackOAuth2Api).toBeDefined();
expect(result?.credentials?.slackOAuth2Api[0]).toContain('My Notion');
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result).toBeNull();
});
it('warns when a private credential is used under a non-manual trigger', () => {
mockConnectedPrivateCred(true);
mockDocumentStore.workflowTriggerNodes = [buildTriggerNode(WEBHOOK_TRIGGER)];
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result?.credentials?.[NOTION_API]).toEqual([
'Private credentials only work in manually triggered workflows. Change the trigger to a Manual trigger, or switch this credential to Static.',
]);
});
it('does not warn when a static (non-resolvable) credential is used under a non-manual trigger', () => {
mockConnectedPrivateCred(false);
mockDocumentStore.workflowTriggerNodes = [buildTriggerNode(WEBHOOK_TRIGGER)];
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result).toBeNull();
});
it('ignores disabled non-manual triggers when computing compatibility', () => {
mockConnectedPrivateCred(true);
mockDocumentStore.workflowTriggerNodes = [
buildTriggerNode(WEBHOOK_TRIGGER, { disabled: true }),
buildTriggerNode(MANUAL_TRIGGER),
];
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result).toBeNull();
});
it('does not warn when no triggers are present', () => {
mockConnectedPrivateCred(true);
mockDocumentStore.workflowTriggerNodes = [];
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result).toBeNull();
});
it('warns when any trigger in a multi-trigger workflow is non-manual', () => {
mockConnectedPrivateCred(true);
mockDocumentStore.workflowTriggerNodes = [
buildTriggerNode(MANUAL_TRIGGER),
buildTriggerNode(WEBHOOK_TRIGGER),
];
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result?.credentials?.[NOTION_API]?.[0]).toContain(
'Private credentials only work in manually triggered workflows',
);
});
it('does not warn when dynamic credentials feature is disabled', () => {
mockedUseDynamicCredentials.mockReturnValue({
isEnabled: computed(() => false),
} as ReturnType<typeof useDynamicCredentials>);
mockConnectedPrivateCred(true);
mockDocumentStore.workflowTriggerNodes = [buildTriggerNode(WEBHOOK_TRIGGER)];
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result).toBeNull();
});
describe('HTTP Request node generic / predefined credential auth', () => {
const OAUTH2_API = 'oAuth2Api';
const httpRequestWithSslAuth: INodeTypeDescription = {
displayName: 'HTTP Request',
name: 'httpRequest',
group: ['transform'],
version: 4.4,
description: 'HTTP Request node',
defaults: { name: 'HTTP Request' },
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
name: 'httpSslAuth',
required: true,
displayOptions: { show: { provideSslCertificates: [true] } },
},
],
properties: [],
};
const buildGenericAuthNode = (): INodeUi =>
createTestNode({
type: 'httpRequest',
typeVersion: 4.4,
parameters: {
authentication: 'genericCredentialType',
genericAuthType: OAUTH2_API,
},
credentials: { [OAUTH2_API]: { id: 'cred-1', name: 'My OAuth2' } },
});
const buildPredefinedAuthNode = (): INodeUi =>
createTestNode({
type: 'httpRequest',
typeVersion: 4.4,
parameters: {
authentication: 'predefinedCredentialType',
nodeCredentialType: OAUTH2_API,
},
credentials: { [OAUTH2_API]: { id: 'cred-1', name: 'My OAuth2' } },
});
const mockHttpCredential = (isResolvable: boolean) => {
const cred = {
id: 'cred-1',
name: 'My OAuth2',
type: OAUTH2_API,
isResolvable,
connectedByMe: true,
};
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.getCredentialTypeByName = vi
.fn()
.mockReturnValue({ name: OAUTH2_API, displayName: 'OAuth2 API' });
credentialsStore.getCredentialsByType = vi.fn().mockReturnValue([cred as never]);
credentialsStore.getCredentialById = vi.fn().mockReturnValue(cred as never);
mockedStore(useNodeTypesStore).getNodeType = vi
.fn()
.mockReturnValue(httpRequestWithSslAuth);
};
it('warns when a private credential is bound via genericCredentialType under a non-manual trigger', () => {
mockHttpCredential(true);
mockDocumentStore.workflowTriggerNodes = [buildTriggerNode(WEBHOOK_TRIGGER)];
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildGenericAuthNode(), httpRequestWithSslAuth);
expect(result?.credentials?.[OAUTH2_API]).toEqual([
'Private credentials only work in manually triggered workflows. Change the trigger to a Manual trigger, or switch this credential to Static.',
]);
});
it('does not warn when a static credential is bound via genericCredentialType under a non-manual trigger', () => {
mockHttpCredential(false);
mockDocumentStore.workflowTriggerNodes = [buildTriggerNode(WEBHOOK_TRIGGER)];
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildGenericAuthNode(), httpRequestWithSslAuth);
expect(result).toBeNull();
});
it('does not warn when a private credential is bound via genericCredentialType under a manual trigger', () => {
mockHttpCredential(true);
mockDocumentStore.workflowTriggerNodes = [buildTriggerNode(MANUAL_TRIGGER)];
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildGenericAuthNode(), httpRequestWithSslAuth);
expect(result).toBeNull();
});
it('warns when a private credential is bound via predefinedCredentialType under a non-manual trigger', () => {
mockHttpCredential(true);
mockDocumentStore.workflowTriggerNodes = [buildTriggerNode(WEBHOOK_TRIGGER)];
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildPredefinedAuthNode(), httpRequestWithSslAuth);
expect(result?.credentials?.[OAUTH2_API]).toEqual([
'Private credentials only work in manually triggered workflows. Change the trigger to a Manual trigger, or switch this credential to Static.',
]);
});
it('does not warn when a static credential is bound via predefinedCredentialType under a non-manual trigger', () => {
mockHttpCredential(false);
mockDocumentStore.workflowTriggerNodes = [buildTriggerNode(WEBHOOK_TRIGGER)];
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildPredefinedAuthNode(), httpRequestWithSslAuth);
expect(result).toBeNull();
});
});
});
it('emits no issue for static (non-resolvable) credential regardless of connectedByMe', () => {
const cred = makePrivateCred({ isResolvable: false, connectedByMe: false });
mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred);
mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]);
describe('precedence', () => {
it('emits only privateNotConnected when both rules would fire (not-connected wins)', () => {
const cred = makePrivateCred({ connectedByMe: false });
mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred);
mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]);
mockDocumentStore.workflowTriggerNodes = [buildTriggerNode(WEBHOOK_TRIGGER)];
const node: INodeUi = createTestNode({
type: 'n8n-nodes-base.notion',
credentials: { notionApi: { id: 'cred-123', name: 'My Notion' } },
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result?.credentials?.[NOTION_API]).toHaveLength(1);
expect(result?.credentials?.[NOTION_API][0]).toContain('My Notion');
expect(result?.credentials?.[NOTION_API][0]).not.toContain('manually triggered workflows');
});
const { getNodeIssues } = useNodeHelpers();
const result = getNodeIssues(notionNodeType, node, mock<Workflow>(), ['parameters']);
expect(result?.credentials).toBeUndefined();
});
it('emits no issue for AI-gateway managed private credential', () => {
const cred = makePrivateCred({ connectedByMe: false });
mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred);
mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]);
const node: INodeUi = createTestNode({
type: 'n8n-nodes-base.notion',
credentials: { notionApi: { id: 'cred-123', name: 'My Notion', __aiGatewayManaged: true } },
});
const { getNodeIssues } = useNodeHelpers();
const result = getNodeIssues(notionNodeType, node, mock<Workflow>(), ['parameters']);
expect(result?.credentials).toBeUndefined();
});
it('preserves declared-loop notSet issue and does not overwrite with private check', () => {
// Credential not set (undefined id) — declared loop fires notSet, private scan must skip
mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([]);
const node: INodeUi = createTestNode({
type: 'n8n-nodes-base.notion',
credentials: {},
});
const { getNodeIssues } = useNodeHelpers();
const result = getNodeIssues(notionNodeType, node, mock<Workflow>(), ['parameters']);
expect(result?.credentials?.notionApi).toBeDefined();
expect(result?.credentials?.notionApi[0]).toContain('Notion');
});
it('emits no issue when dynamic credentials feature is disabled', () => {
mockedUseDynamicCredentials.mockReturnValue({
isEnabled: computed(() => false),
} as ReturnType<typeof useDynamicCredentials>);
const cred = makePrivateCred({ connectedByMe: false });
mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred);
mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]);
const node: INodeUi = createTestNode({
type: 'n8n-nodes-base.notion',
credentials: { notionApi: { id: 'cred-123', name: 'My Notion' } },
});
const { getNodeIssues } = useNodeHelpers();
const result = getNodeIssues(notionNodeType, node, mock<Workflow>(), ['parameters']);
expect(result?.credentials?.notionApi).toBeUndefined();
});
});
});

View File

@ -6,7 +6,7 @@ import {
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
} from '@/app/constants';
import { NodeHelpers, NodeConnectionTypes } from 'n8n-workflow';
import { NodeHelpers, NodeConnectionTypes, MANUAL_TRIGGER_NODE_TYPES } from 'n8n-workflow';
import type {
INodeProperties,
INodeCredentialDescription,
@ -414,12 +414,21 @@ export function useNodeHelpers() {
return null;
}
function workflowHasIncompatibleTrigger(): boolean {
const triggers = workflowDocumentStore.value.workflowTriggerNodes;
return triggers.some(
(trigger) => !trigger.disabled && !MANUAL_TRIGGER_NODE_TYPES.includes(trigger.type),
);
}
function collectPrivateCredentialIssues(
node: INodeUi,
foundIssues: INodeIssueObjectProperty,
): void {
if (!isDynamicCredentialsEnabled.value) return;
const incompatibleTrigger = workflowHasIncompatibleTrigger();
for (const [credTypeName, details] of Object.entries(node.credentials ?? {})) {
if (foundIssues[credTypeName]?.length) continue;
if (!details?.id || details.__aiGatewayManaged) continue;
@ -433,6 +442,10 @@ export function useNodeHelpers() {
interpolate: { name: credential.name },
}),
];
} else if (incompatibleTrigger) {
foundIssues[credTypeName] = [
i18n.baseText('nodeIssues.credentials.privateRequiresManualTrigger'),
];
}
}
}

View File

@ -121,6 +121,13 @@ export const ALIBABA_CLOUD_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.aliba
export const MOONSHOT_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.moonshot';
export const MINIMAX_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.minimax';
// Trigger types that execute with a manual-user identity. Used to gate
// features (like private credentials) that depend on per-user runtime state.
export const MANUAL_TRIGGER_NODE_TYPES: readonly string[] = [
MANUAL_TRIGGER_NODE_TYPE,
MANUAL_CHAT_TRIGGER_LANGCHAIN_NODE_TYPE,
];
export const AI_VENDOR_NODE_TYPES = [
OPENAI_LANGCHAIN_NODE_TYPE,
ANTHROPIC_LANGCHAIN_NODE_TYPE,