From 5de0d32e2dfba3c49e83eeb7d563214bdbff96a4 Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Mon, 1 Jun 2026 10:19:41 +0200 Subject: [PATCH] feat(editor): Validate private credentials only run under manual triggers (#31211) --- .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../composables/useCanvasOperations.test.ts | 77 +++ .../app/composables/useCanvasOperations.ts | 14 + .../app/composables/useNodeHelpers.test.ts | 464 ++++++++++++++---- .../src/app/composables/useNodeHelpers.ts | 15 +- packages/workflow/src/constants.ts | 7 + 6 files changed, 469 insertions(+), 109 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index ef69c074df4..8e438c572ce 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts index 677008f5747..4f1e58ab279 100644 --- a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts @@ -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); diff --git a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts index 55b311c766f..729585424e1 100644 --- a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts +++ b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts @@ -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 diff --git a/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.test.ts b/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.test.ts index 72805155d7b..801bca656ad 100644 --- a/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.test.ts @@ -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 => + 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(), ['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(), [ + '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(), ['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(), [ + '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(), ['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(), [ + '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(), ['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(), ['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); + + 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(), [ + '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(), ['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); + 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(), ['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(), ['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(), ['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); - - 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(), ['parameters']); - expect(result?.credentials?.notionApi).toBeUndefined(); }); }); }); diff --git a/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.ts b/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.ts index c1954f62b4f..61987afe99c 100644 --- a/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.ts +++ b/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.ts @@ -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'), + ]; } } } diff --git a/packages/workflow/src/constants.ts b/packages/workflow/src/constants.ts index 32bf8693e91..13b29f881c9 100644 --- a/packages/workflow/src/constants.ts +++ b/packages/workflow/src/constants.ts @@ -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,