mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-02 17:57:06 +02:00
feat(editor): Validate private credentials only run under manual triggers (#31211)
This commit is contained in:
parent
be3241dc22
commit
5de0d32e2d
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user