mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-18 05:59:42 +02:00
fix(editor): Fix non-project credentials sometimes appearing as options (#36715)
This commit is contained in:
parent
e75bf3e052
commit
ffd2b35595
|
|
@ -63,7 +63,7 @@ vi.mock('@n8n/composables/useToast', () => ({
|
|||
const mockFetchAllCredentialsForWorkflow = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/features/credentials/credentials.store', () => ({
|
||||
useCredentialsStore: vi.fn(() => ({
|
||||
fetchAllCredentialsForWorkflow: mockFetchAllCredentialsForWorkflow,
|
||||
fetchUsableCredentials: mockFetchAllCredentialsForWorkflow,
|
||||
})),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ export function usePostMessageHandler({ currentWorkflowDocumentStore }: PostMess
|
|||
return;
|
||||
}
|
||||
|
||||
await credentialsStore.fetchAllCredentialsForWorkflow({ workflowId: data.workflowData.id });
|
||||
await credentialsStore.fetchUsableCredentials({ workflowId: data.workflowData.id });
|
||||
|
||||
const wfId = workflowsStore.workflowId;
|
||||
if (wfId) {
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ export function useWorkflowInitialization() {
|
|||
options = { projectId };
|
||||
}
|
||||
|
||||
await credentialsStore.fetchAllCredentialsForWorkflow(options);
|
||||
await credentialsStore.fetchUsableCredentials(options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -830,7 +830,7 @@ async function loadCredentials() {
|
|||
options = { projectId };
|
||||
}
|
||||
|
||||
await credentialsStore.fetchAllCredentialsForWorkflow(options);
|
||||
await credentialsStore.fetchUsableCredentials(options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -33,13 +33,13 @@ let createObjectURLSpy: ReturnType<typeof vi.spyOn> | undefined;
|
|||
let revokeObjectURLSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
let anchorClickSpy: ReturnType<typeof vi.spyOn> | undefined;
|
||||
const {
|
||||
fetchAllCredentialsForWorkflowMock,
|
||||
fetchUsableCredentialsMock,
|
||||
fetchAllCredentialsMock,
|
||||
fetchCredentialTypesMock,
|
||||
setCredentialsMock,
|
||||
agentPermissionsMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchAllCredentialsForWorkflowMock: vi.fn().mockResolvedValue(undefined),
|
||||
fetchUsableCredentialsMock: vi.fn().mockResolvedValue(undefined),
|
||||
fetchAllCredentialsMock: vi.fn().mockResolvedValue(undefined),
|
||||
fetchCredentialTypesMock: vi.fn().mockResolvedValue(undefined),
|
||||
setCredentialsMock: vi.fn(),
|
||||
|
|
@ -88,7 +88,7 @@ vi.mock('@/features/credentials/credentials.store', () => ({
|
|||
allCredentials: [],
|
||||
getCredentialsByType: () => [],
|
||||
fetchAllCredentials: fetchAllCredentialsMock,
|
||||
fetchAllCredentialsForWorkflow: fetchAllCredentialsForWorkflowMock,
|
||||
fetchUsableCredentials: fetchUsableCredentialsMock,
|
||||
fetchCredentialTypes: fetchCredentialTypesMock,
|
||||
setCredentials: setCredentialsMock,
|
||||
}),
|
||||
|
|
@ -783,7 +783,7 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => {
|
|||
await renderView();
|
||||
|
||||
expect(setCredentialsMock).toHaveBeenCalledWith([]);
|
||||
expect(fetchAllCredentialsForWorkflowMock).toHaveBeenCalledWith({ projectId: 'p1' });
|
||||
expect(fetchUsableCredentialsMock).toHaveBeenCalledWith({ projectId: 'p1' });
|
||||
expect(fetchAllCredentialsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import AgentChannelsSection from '../components/AgentChannelsSection.vue';
|
|||
vi.mock('@/features/credentials/credentials.store', () => ({
|
||||
useCredentialsStore: () => ({
|
||||
setCredentials: vi.fn(),
|
||||
fetchAllCredentialsForWorkflow: vi.fn().mockResolvedValue([]),
|
||||
fetchUsableCredentials: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import AgentVectorStoresModal from '../components/AgentVectorStoresModal.vue';
|
|||
|
||||
const closeModalMock = vi.fn();
|
||||
const openNewCredentialMock = vi.fn();
|
||||
const fetchAllCredentialsForWorkflowMock = vi.fn();
|
||||
const fetchUsableCredentialsMock = vi.fn();
|
||||
const testAgentVectorStoreMock = vi.fn();
|
||||
const showMessageMock = vi.fn();
|
||||
const showErrorMock = vi.fn();
|
||||
|
|
@ -45,7 +45,7 @@ vi.mock('@/features/credentials/credentials.constants', () => ({
|
|||
|
||||
vi.mock('@/features/credentials/credentials.store', () => ({
|
||||
useCredentialsStore: () => ({
|
||||
fetchAllCredentialsForWorkflow: fetchAllCredentialsForWorkflowMock,
|
||||
fetchUsableCredentials: fetchUsableCredentialsMock,
|
||||
getCredentialTypeByName: () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
|
@ -163,7 +163,7 @@ const expectedVectorStore = {
|
|||
describe('AgentVectorStoresModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchAllCredentialsForWorkflowMock.mockResolvedValue([qdrantCredential, openAiCredential]);
|
||||
fetchUsableCredentialsMock.mockResolvedValue([qdrantCredential, openAiCredential]);
|
||||
});
|
||||
|
||||
it('lists all four vector store providers with a Connect button each', async () => {
|
||||
|
|
@ -353,7 +353,7 @@ describe('AgentVectorStoresModal', () => {
|
|||
|
||||
it('clears provider-specific fields when going back to the provider picker', async () => {
|
||||
const pineconeCredential = { id: 'pinecone-cred-1', name: 'My Pinecone', type: 'pineconeApi' };
|
||||
fetchAllCredentialsForWorkflowMock.mockResolvedValue([
|
||||
fetchUsableCredentialsMock.mockResolvedValue([
|
||||
pineconeCredential,
|
||||
qdrantCredential,
|
||||
openAiCredential,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ vi.mock('@/app/stores/ui.store', () => ({
|
|||
vi.mock('@/features/credentials/credentials.store', () => ({
|
||||
useCredentialsStore: () => ({
|
||||
setCredentials: vi.fn(),
|
||||
fetchAllCredentialsForWorkflow: fetchCredentials,
|
||||
fetchUsableCredentials: fetchCredentials,
|
||||
getCredentialTypeByName: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ vi.mock('@/features/credentials/credentials.store', () => ({
|
|||
.flat()
|
||||
.find((c) => c.id === id),
|
||||
fetchCredentialTypes: vi.fn().mockResolvedValue(undefined),
|
||||
fetchAllCredentialsForWorkflow: vi.fn().mockResolvedValue(undefined),
|
||||
fetchUsableCredentials: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ vi.mock('@/app/stores/ui.store', () => ({
|
|||
vi.mock('@/features/credentials/credentials.store', () => ({
|
||||
useCredentialsStore: () => ({
|
||||
setCredentials: vi.fn(),
|
||||
fetchAllCredentialsForWorkflow: mocks.fetchCredentials,
|
||||
fetchUsableCredentials: mocks.fetchCredentials,
|
||||
deleteCredential: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ export function useSlackChannelRuntime(context: AgentChannelRuntimeContext): Sla
|
|||
|
||||
try {
|
||||
credentialsStore.setCredentials([]);
|
||||
const credentials = await credentialsStore.fetchAllCredentialsForWorkflow({
|
||||
const credentials = await credentialsStore.fetchUsableCredentials({
|
||||
projectId: context.projectId.value,
|
||||
});
|
||||
const credential = credentials.find(
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ async function loadChannelDetails() {
|
|||
|
||||
try {
|
||||
credentialsStore.setCredentials([]);
|
||||
const credentials = await credentialsStore.fetchAllCredentialsForWorkflow({
|
||||
const credentials = await credentialsStore.fetchUsableCredentials({
|
||||
projectId: props.projectId,
|
||||
});
|
||||
credentialNamesById.value = Object.fromEntries(
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ function onEmbeddingModelUpdate(model: string) {
|
|||
async function loadCredentials() {
|
||||
credentialsLoading.value = true;
|
||||
try {
|
||||
const allCredentials = await credentialsStore.fetchAllCredentialsForWorkflow({
|
||||
const allCredentials = await credentialsStore.fetchUsableCredentials({
|
||||
projectId: props.data.projectId,
|
||||
});
|
||||
const types = new Set<string>();
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) {
|
|||
credentialsLoading.value = true;
|
||||
try {
|
||||
credentialsStore.setCredentials([]);
|
||||
const allCredentials = await credentialsStore.fetchAllCredentialsForWorkflow({
|
||||
const allCredentials = await credentialsStore.fetchUsableCredentials({
|
||||
projectId: projectId.value,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export function useAgentModelCredentials(userId: string, projectId: MaybeRefOrGe
|
|||
|
||||
await Promise.all([
|
||||
credentialsStore.fetchCredentialTypes(false),
|
||||
credentialsStore.fetchAllCredentialsForWorkflow({ projectId: id }),
|
||||
credentialsStore.fetchUsableCredentials({ projectId: id }),
|
||||
]);
|
||||
|
||||
isInitialized.value = true;
|
||||
|
|
|
|||
|
|
@ -1503,7 +1503,7 @@ async function initialize({ preserveState = false }: { preserveState?: boolean }
|
|||
// credentials the current user can use in this project context.
|
||||
credentialsStore.setCredentials([]);
|
||||
await Promise.all([
|
||||
credentialsStore.fetchAllCredentialsForWorkflow({ projectId: targetProjectId }),
|
||||
credentialsStore.fetchUsableCredentials({ projectId: targetProjectId }),
|
||||
credentialsStore.fetchCredentialTypes(false),
|
||||
]).catch(() => undefined);
|
||||
if (!isCurrentInitialization()) return;
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ vi.mock('@/features/credentials/credentials.store', () => ({
|
|||
useCredentialsStore: () => ({
|
||||
fetchCredentialTypes: vi.fn().mockResolvedValue(undefined),
|
||||
fetchAllCredentials: vi.fn().mockResolvedValue(undefined),
|
||||
fetchAllCredentialsForWorkflow: vi.fn().mockResolvedValue([]),
|
||||
fetchUsableCredentials: vi.fn().mockResolvedValue([]),
|
||||
getCredentialById: vi.fn().mockReturnValue(undefined),
|
||||
getCredentialsByType: vi.fn().mockReturnValue([]),
|
||||
getCredentialTypeByName: vi.fn().mockReturnValue(undefined),
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export function useChatCredentials(userId: string) {
|
|||
if (personalProject) {
|
||||
await Promise.all([
|
||||
credentialsStore.fetchCredentialTypes(false),
|
||||
credentialsStore.fetchAllCredentialsForWorkflow({ projectId: personalProject.id }),
|
||||
credentialsStore.fetchUsableCredentials({ projectId: personalProject.id }),
|
||||
]);
|
||||
|
||||
isInitialized.value = true;
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ describe('InstanceAiCredentialSetup', () => {
|
|||
|
||||
const credentialsStore = useCredentialsStore();
|
||||
vi.spyOn(credentialsStore, 'fetchAllCredentials').mockResolvedValue([]);
|
||||
vi.spyOn(credentialsStore, 'fetchAllCredentialsForWorkflow').mockResolvedValue([]);
|
||||
vi.spyOn(credentialsStore, 'fetchUsableCredentials').mockResolvedValue([]);
|
||||
vi.spyOn(credentialsStore, 'fetchCredentialTypes').mockResolvedValue(undefined);
|
||||
// The card renders the NodeCredentials picker when the store has a usable
|
||||
// credential of the type; default to one so the picker-based tests render it.
|
||||
|
|
@ -254,7 +254,7 @@ describe('InstanceAiCredentialSetup', () => {
|
|||
});
|
||||
await nextTick();
|
||||
|
||||
expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({
|
||||
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({
|
||||
projectId: 'project-team-1',
|
||||
});
|
||||
expect(credentialsStore.fetchAllCredentials).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ onMounted(async () => {
|
|||
try {
|
||||
await Promise.all([
|
||||
props.projectId
|
||||
? credentialsStore.fetchAllCredentialsForWorkflow({ projectId: props.projectId })
|
||||
? credentialsStore.fetchUsableCredentials({ projectId: props.projectId })
|
||||
: credentialsStore.fetchAllCredentials(),
|
||||
credentialsStore.fetchCredentialTypes(false),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ describe('useWorkflowSetupBootstrap', () => {
|
|||
credentialsStore = mockedStore(useCredentialsStore);
|
||||
nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
|
||||
credentialsStore.fetchAllCredentialsForWorkflow = vi.fn().mockResolvedValue([]);
|
||||
credentialsStore.fetchUsableCredentials = vi.fn().mockResolvedValue([]);
|
||||
credentialsStore.fetchCredentialTypes = vi.fn().mockResolvedValue(undefined);
|
||||
nodeTypesStore.loadNodeTypesIfNotLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
});
|
||||
|
|
@ -25,7 +25,7 @@ describe('useWorkflowSetupBootstrap', () => {
|
|||
const { bootstrap } = useWorkflowSetupBootstrap(ref(undefined));
|
||||
|
||||
await expect(bootstrap()).rejects.toThrow('useWorkflowSetupBootstrap: workflowId is required');
|
||||
expect(credentialsStore.fetchAllCredentialsForWorkflow).not.toHaveBeenCalled();
|
||||
expect(credentialsStore.fetchUsableCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches workflow-scoped credentials and flips isReady after success', async () => {
|
||||
|
|
@ -35,7 +35,7 @@ describe('useWorkflowSetupBootstrap', () => {
|
|||
|
||||
await bootstrap();
|
||||
|
||||
expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({
|
||||
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({
|
||||
workflowId: 'workflow-1',
|
||||
});
|
||||
expect(credentialsStore.fetchCredentialTypes).toHaveBeenCalled();
|
||||
|
|
@ -44,9 +44,7 @@ describe('useWorkflowSetupBootstrap', () => {
|
|||
});
|
||||
|
||||
it('flips isReady to true even when one of the parallel calls fails', async () => {
|
||||
credentialsStore.fetchAllCredentialsForWorkflow = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('network'));
|
||||
credentialsStore.fetchUsableCredentials = vi.fn().mockRejectedValue(new Error('network'));
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const { isReady, bootstrap } = useWorkflowSetupBootstrap(ref('workflow-1'));
|
||||
|
|
@ -66,7 +64,7 @@ describe('useWorkflowSetupBootstrap', () => {
|
|||
workflowId.value = 'workflow-2';
|
||||
await bootstrap();
|
||||
|
||||
expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({
|
||||
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({
|
||||
workflowId: 'workflow-2',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export function useWorkflowSetupBootstrap(workflowId: Ref<string | undefined>):
|
|||
}
|
||||
try {
|
||||
await Promise.all([
|
||||
credentialsStore.fetchAllCredentialsForWorkflow({ workflowId: wid }),
|
||||
credentialsStore.fetchUsableCredentials({ workflowId: wid }),
|
||||
credentialsStore.fetchCredentialTypes(false),
|
||||
nodeTypesStore.loadNodeTypesIfNotLoaded(),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ vi.mock('@/features/agents/composables/useAgentApi', () => ({
|
|||
vi.mock('@/features/credentials/credentials.store', () => ({
|
||||
useCredentialsStore: () => ({
|
||||
setCredentials: vi.fn(),
|
||||
fetchAllCredentialsForWorkflow: mocks.fetchCredentials,
|
||||
fetchUsableCredentials: mocks.fetchCredentials,
|
||||
deleteCredential: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import type { ICredentialType, INodeTypeDescription } from 'n8n-workflow';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import type { ICredentialsResponse } from '../credentials.types';
|
||||
import * as credentialsApi from '../credentials.api';
|
||||
import { useCredentialsStore } from '../credentials.store';
|
||||
|
|
@ -220,6 +221,222 @@ describe('credentials.store', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('fetchUsableCredentials', () => {
|
||||
const credential = (
|
||||
overrides: Partial<ICredentialsResponse> & Pick<ICredentialsResponse, 'id'>,
|
||||
): ICredentialsResponse =>
|
||||
mock<ICredentialsResponse>({
|
||||
name: `Credential ${overrides.id}`,
|
||||
type: 'httpBasicAuth',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const inScope = credential({ id: 'in-scope', name: 'Project credential' });
|
||||
const outOfScope = credential({ id: 'out-of-scope', name: 'Personal credential' });
|
||||
|
||||
it('populates the usable slice and flips the fetched flag', async () => {
|
||||
const store = useCredentialsStore();
|
||||
expect(store.hasFetchedUsableCredentials).toBe(false);
|
||||
|
||||
vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope]);
|
||||
|
||||
await store.fetchUsableCredentials({ workflowId: 'wf-1' });
|
||||
|
||||
expect(credentialsApi.getUsableCredentials).toHaveBeenCalledWith(
|
||||
mockRootStore.restApiContext,
|
||||
{ workflowId: 'wf-1' },
|
||||
);
|
||||
expect(store.hasFetchedUsableCredentials).toBe(true);
|
||||
expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([inScope]);
|
||||
// The flat map keeps its existing replace semantics.
|
||||
expect(store.allCredentials).toEqual([inScope]);
|
||||
});
|
||||
|
||||
it('reads an unfetched slice as empty rather than falling back to the flat map', async () => {
|
||||
const store = useCredentialsStore();
|
||||
|
||||
vi.spyOn(credentialsApi, 'getAllCredentials').mockResolvedValue([outOfScope]);
|
||||
await store.fetchAllCredentials();
|
||||
|
||||
expect(store.allCredentials).toEqual([outOfScope]);
|
||||
expect(store.hasFetchedUsableCredentials).toBe(false);
|
||||
expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the usable slice when a later unscoped fetch widens the flat map', async () => {
|
||||
const store = useCredentialsStore();
|
||||
|
||||
vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope]);
|
||||
vi.spyOn(credentialsApi, 'getAllCredentials').mockResolvedValue([inScope, outOfScope]);
|
||||
|
||||
await store.fetchUsableCredentials({ projectId: 'project-1' });
|
||||
await store.fetchAllCredentials();
|
||||
|
||||
expect(store.allCredentials).toHaveLength(2);
|
||||
expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([inScope]);
|
||||
});
|
||||
|
||||
it('keeps the usable slice when the unscoped fetch resolved first', async () => {
|
||||
const store = useCredentialsStore();
|
||||
|
||||
vi.spyOn(credentialsApi, 'getAllCredentials').mockResolvedValue([inScope, outOfScope]);
|
||||
vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope]);
|
||||
|
||||
await store.fetchAllCredentials();
|
||||
await store.fetchUsableCredentials({ projectId: 'project-1' });
|
||||
|
||||
expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([inScope]);
|
||||
});
|
||||
|
||||
it('returns an empty list for a type with no usable credentials', async () => {
|
||||
const store = useCredentialsStore();
|
||||
|
||||
vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope]);
|
||||
await store.fetchUsableCredentials({ workflowId: 'wf-1' });
|
||||
|
||||
expect(store.getUsableCredentialByType('unknownType')).toEqual([]);
|
||||
});
|
||||
|
||||
it('never yields undefined entries for a node whose types are only partly usable', async () => {
|
||||
const store = useCredentialsStore();
|
||||
|
||||
mockNodeTypesStore.getNodeType.mockReturnValue(
|
||||
mock<INodeTypeDescription>({
|
||||
credentials: [{ name: 'httpBasicAuth' }, { name: 'oAuth2Api' }],
|
||||
}),
|
||||
);
|
||||
vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope]);
|
||||
await store.fetchUsableCredentials({ workflowId: 'wf-1' });
|
||||
|
||||
const credentials = store.allUsableCredentialsForNode(
|
||||
mock<INodeUi>({ type: 'n8n-nodes-base.httpRequest', typeVersion: 1 }),
|
||||
);
|
||||
|
||||
expect(credentials).toEqual([inScope]);
|
||||
});
|
||||
|
||||
it('drops the slice while a different scope is in flight', async () => {
|
||||
const store = useCredentialsStore();
|
||||
|
||||
vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope]);
|
||||
await store.fetchUsableCredentials({ workflowId: 'wf-1' });
|
||||
|
||||
let resolveSecond: (credentials: ICredentialsResponse[]) => void = () => {};
|
||||
vi.spyOn(credentialsApi, 'getUsableCredentials').mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
}),
|
||||
);
|
||||
const pending = store.fetchUsableCredentials({ workflowId: 'wf-2' });
|
||||
|
||||
// The previous workflow's credentials must not stand in for the new scope.
|
||||
expect(store.hasFetchedUsableCredentials).toBe(false);
|
||||
expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([]);
|
||||
|
||||
resolveSecond([outOfScope]);
|
||||
await pending;
|
||||
|
||||
expect(store.hasFetchedUsableCredentials).toBe(true);
|
||||
expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([outOfScope]);
|
||||
});
|
||||
|
||||
it('ignores a response for a scope that is no longer active', async () => {
|
||||
const store = useCredentialsStore();
|
||||
|
||||
let resolveFirst: (credentials: ICredentialsResponse[]) => void = () => {};
|
||||
vi.spyOn(credentialsApi, 'getUsableCredentials').mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
);
|
||||
const stale = store.fetchUsableCredentials({ workflowId: 'wf-1' });
|
||||
|
||||
vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope]);
|
||||
await store.fetchUsableCredentials({ workflowId: 'wf-2' });
|
||||
|
||||
resolveFirst([outOfScope]);
|
||||
await stale;
|
||||
|
||||
expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([inScope]);
|
||||
});
|
||||
|
||||
it('ignores an older response for the scope already loaded', async () => {
|
||||
const store = useCredentialsStore();
|
||||
|
||||
// A refresh — the one a quick connect triggers, say — can overtake a fetch the
|
||||
// same scope started earlier; the newest answer has to win.
|
||||
let resolveFirst: (credentials: ICredentialsResponse[]) => void = () => {};
|
||||
vi.spyOn(credentialsApi, 'getUsableCredentials').mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
);
|
||||
const stale = store.fetchUsableCredentials({ workflowId: 'wf-1' });
|
||||
|
||||
vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope, outOfScope]);
|
||||
await store.fetchUsableCredentials({ workflowId: 'wf-1' });
|
||||
|
||||
resolveFirst([inScope]);
|
||||
await stale;
|
||||
|
||||
expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([outOfScope, inScope]);
|
||||
});
|
||||
|
||||
it('keeps the slice when the same scope is fetched again', async () => {
|
||||
const store = useCredentialsStore();
|
||||
|
||||
vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope]);
|
||||
await store.fetchUsableCredentials({ workflowId: 'wf-1' });
|
||||
|
||||
const pending = store.fetchUsableCredentials({ workflowId: 'wf-1' });
|
||||
|
||||
expect(store.hasFetchedUsableCredentials).toBe(true);
|
||||
expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([inScope]);
|
||||
await pending;
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshUsableCredentials', () => {
|
||||
const inScope = mock<ICredentialsResponse>({
|
||||
id: 'in-scope',
|
||||
name: 'A project credential',
|
||||
type: 'httpBasicAuth',
|
||||
});
|
||||
const connected = mock<ICredentialsResponse>({
|
||||
id: 'connected',
|
||||
name: 'B just connected',
|
||||
type: 'httpBasicAuth',
|
||||
});
|
||||
|
||||
it('re-reads the scope the slice was last fetched for', async () => {
|
||||
const store = useCredentialsStore();
|
||||
|
||||
const fetchSpy = vi
|
||||
.spyOn(credentialsApi, 'getUsableCredentials')
|
||||
.mockResolvedValue([inScope]);
|
||||
await store.fetchUsableCredentials({ projectId: 'project-1' });
|
||||
|
||||
fetchSpy.mockResolvedValue([inScope, connected]);
|
||||
await store.refreshUsableCredentials();
|
||||
|
||||
expect(fetchSpy).toHaveBeenLastCalledWith(mockRootStore.restApiContext, {
|
||||
projectId: 'project-1',
|
||||
});
|
||||
expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([inScope, connected]);
|
||||
});
|
||||
|
||||
it('does nothing when no scoped fetch has happened', async () => {
|
||||
const store = useCredentialsStore();
|
||||
|
||||
const fetchSpy = vi.spyOn(credentialsApi, 'getUsableCredentials');
|
||||
await store.refreshUsableCredentials();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(store.hasFetchedUsableCredentials).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNewCredential', () => {
|
||||
it('should pass isGlobal parameter to API when creating credential', async () => {
|
||||
const store = useCredentialsStore();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { shallowRef, ref, computed, nextTick } from 'vue';
|
||||
import { describe, it, vi, beforeEach } from 'vitest';
|
||||
import { shallowRef, ref, computed, nextTick, watchSyncEffect } from 'vue';
|
||||
import { describe, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { screen, within, waitFor } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
|
@ -177,6 +177,7 @@ describe('NodeCredentials', () => {
|
|||
typeof shallowRef<ReturnType<typeof useWorkflowDocumentStore> | null>
|
||||
>;
|
||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||
let stopCredentialsMirror: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -209,7 +210,18 @@ describe('NodeCredentials', () => {
|
|||
|
||||
credentialsStore = mockedStore(useCredentialsStore);
|
||||
// Component triggers this on mount; avoid a real XHR with stubActions: false.
|
||||
credentialsStore.fetchAllCredentialsForWorkflow = vi.fn().mockResolvedValue([]);
|
||||
credentialsStore.fetchUsableCredentials = vi
|
||||
.fn()
|
||||
.mockImplementation(async () => Object.values(credentialsStore.usableCredentials));
|
||||
|
||||
// The picker reads `usableCredentials`, the slice only the scoped fetch writes.
|
||||
// Tests seed the flat map, and by the time an NDV mounts in production the
|
||||
// scoped fetch has already run (useWorkflowInitialization), so mirror the seed
|
||||
// into the slice. Tests that need the two to diverge stop the mirror first.
|
||||
stopCredentialsMirror = watchSyncEffect(() => {
|
||||
credentialsStore.usableCredentials = { ...credentialsStore.state.credentials };
|
||||
});
|
||||
credentialsStore.hasFetchedUsableCredentials = true;
|
||||
|
||||
ndvStore = mockedStore(useNDVStore, createWorkflowDocumentId('1'));
|
||||
uiStore = mockedStore(useUIStore);
|
||||
|
|
@ -233,6 +245,10 @@ describe('NodeCredentials', () => {
|
|||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopCredentialsMirror?.();
|
||||
});
|
||||
|
||||
it('should display available credentials in the dropdown', async () => {
|
||||
ndvStore.activeNode = httpNode;
|
||||
credentialsStore.state.credentials = {
|
||||
|
|
@ -248,6 +264,27 @@ describe('NodeCredentials', () => {
|
|||
expect(screen.queryByText('OpenAi account')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not offer a credential the scoped fetch left out of the usable slice', async () => {
|
||||
// An unscoped fetchAllCredentials from anywhere in the app can fill the flat
|
||||
// map with credentials from other projects while the NDV is open (IAM-1241).
|
||||
stopCredentialsMirror();
|
||||
ndvStore.activeNode = httpNode;
|
||||
credentialsStore.state.credentials = {
|
||||
c8vqdPpPClh4TgIO: createCredential(),
|
||||
'personal-cred': createCredential({ id: 'personal-cred', name: 'Personal OpenAi account' }),
|
||||
};
|
||||
credentialsStore.usableCredentials = {
|
||||
c8vqdPpPClh4TgIO: createCredential(),
|
||||
};
|
||||
|
||||
renderComponent();
|
||||
|
||||
await userEvent.click(screen.getByTestId('node-credentials-select'));
|
||||
|
||||
expect(screen.queryByText('OpenAi account')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Personal OpenAi account')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('replaces the type-derived field label when credentialsFieldLabel is set', () => {
|
||||
ndvStore.activeNode = httpNode;
|
||||
credentialsStore.state.credentials = {
|
||||
|
|
@ -287,7 +324,7 @@ describe('NodeCredentials', () => {
|
|||
|
||||
renderComponent();
|
||||
|
||||
expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({
|
||||
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({
|
||||
workflowId: '1',
|
||||
});
|
||||
});
|
||||
|
|
@ -301,7 +338,7 @@ describe('NodeCredentials', () => {
|
|||
|
||||
renderComponent({ props: { skipCredentialsFetch: true } });
|
||||
|
||||
expect(credentialsStore.fetchAllCredentialsForWorkflow).not.toHaveBeenCalled();
|
||||
expect(credentialsStore.fetchUsableCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch credentials scoped to the project for an unsaved workflow', () => {
|
||||
|
|
@ -312,7 +349,7 @@ describe('NodeCredentials', () => {
|
|||
|
||||
renderComponent();
|
||||
|
||||
expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({
|
||||
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
});
|
||||
});
|
||||
|
|
@ -326,7 +363,7 @@ describe('NodeCredentials', () => {
|
|||
|
||||
renderComponent();
|
||||
|
||||
expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({
|
||||
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({
|
||||
projectId: 'personal-project',
|
||||
});
|
||||
});
|
||||
|
|
@ -2411,6 +2448,46 @@ describe('NodeCredentials', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should not auto-enable the gateway before the scoped fetch has resolved', async () => {
|
||||
// An unfetched slice reads as "no credentials" — acting on it would switch a
|
||||
// node that has a perfectly good credential onto n8n credits (IAM-1241).
|
||||
stopCredentialsMirror();
|
||||
const ownCred = {
|
||||
id: 'cred-1',
|
||||
name: 'My Google Key',
|
||||
type: 'googlePalmApi',
|
||||
isManaged: false,
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
};
|
||||
credentialsStore.hasFetchedUsableCredentials = false;
|
||||
credentialsStore.usableCredentials = {};
|
||||
credentialsStore.getCredentialById = vi.fn().mockReturnValue(ownCred);
|
||||
const nodeWithAction: INodeUi = {
|
||||
...googleAiNode,
|
||||
parameters: { resource: 'chat', operation: 'message' },
|
||||
};
|
||||
ndvStore.activeNode = nodeWithAction;
|
||||
|
||||
const { emitted } = renderComponent({
|
||||
props: { node: nodeWithAction, overrideCredType: 'googlePalmApi' },
|
||||
});
|
||||
|
||||
expect(emitted('credentialSelected')).toBeFalsy();
|
||||
|
||||
// Once the scope lands the user's own credential is selected instead.
|
||||
credentialsStore.usableCredentials = { 'cred-1': ownCred };
|
||||
credentialsStore.hasFetchedUsableCredentials = true;
|
||||
await nextTick();
|
||||
|
||||
const payload = ((emitted('credentialSelected')?.[0] as unknown[]) ?? [])[0] as {
|
||||
properties: { credentials: Record<string, unknown> };
|
||||
};
|
||||
expect(payload.properties.credentials['googlePalmApi']).toEqual(
|
||||
expect.objectContaining({ id: 'cred-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should auto-enable gateway credential on mount for a directly-supported single-cred node in the NDV (show-all, no override)', () => {
|
||||
// Real NDV config: show-all is true and no overrideCredType. Regression guard
|
||||
// for the sibling-fallback change not clobbering directly-supported types.
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { useI18n } from '@n8n/i18n';
|
|||
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||
import { ChatHubToolContextKey, CREDENTIAL_ONLY_NODE_PREFIX } from '@/app/constants';
|
||||
import { ndvEventBus } from '@/features/ndv/shared/ndv.eventBus';
|
||||
import { useCredentialsStore } from '../credentials.store';
|
||||
import { useCredentialsStore, type CredentialFetchScope } from '../credentials.store';
|
||||
import { useQuickConnect } from '../quickConnect/composables/useQuickConnect';
|
||||
import { useCredentialOAuth } from '../composables/useCredentialOAuth';
|
||||
import QuickConnectButton from '../quickConnect/components/QuickConnectButton.vue';
|
||||
|
|
@ -374,6 +374,15 @@ watch(
|
|||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
// Started here rather than in `onMounted`: the request drops the slice held for a
|
||||
// different workflow or project synchronously, and that has to happen before the
|
||||
// watchers below and the first render read it — otherwise the picker's opening
|
||||
// frame lists the previously opened scope's credentials.
|
||||
const initialFetchScope = props.skipCredentialsFetch ? undefined : getCredentialFetchScope();
|
||||
if (initialFetchScope) {
|
||||
void credentialsStore.fetchUsableCredentials(initialFetchScope);
|
||||
}
|
||||
|
||||
let hasEvaluatedCredentials = false;
|
||||
|
||||
// Select most recent credential by default
|
||||
|
|
@ -382,6 +391,10 @@ watch(
|
|||
(types) => {
|
||||
if (props.skipAutoSelect) return;
|
||||
if (types.length === 0) return;
|
||||
// Before the scoped fetch lands there are no options to pick from, which would
|
||||
// read as "no credentials exist" and auto-enable the AI Gateway below. The
|
||||
// watcher re-fires once the fetch populates the slice.
|
||||
if (!credentialsStore.hasFetchedUsableCredentials) return;
|
||||
|
||||
const isInitialEvaluation = !hasEvaluatedCredentials;
|
||||
hasEvaluatedCredentials = true;
|
||||
|
|
@ -432,7 +445,7 @@ watch(
|
|||
{ immediate: true },
|
||||
);
|
||||
|
||||
function getCredentialFetchScope(): { workflowId: string } | { projectId: string } | undefined {
|
||||
function getCredentialFetchScope(): CredentialFetchScope | undefined {
|
||||
const workflowId = workflowDocumentStore?.value.workflowId;
|
||||
if (workflowId && !workflowsStore.isNewWorkflow) {
|
||||
return { workflowId };
|
||||
|
|
@ -467,6 +480,18 @@ onMounted(() => {
|
|||
}
|
||||
}
|
||||
|
||||
// Let the server decide whether the mutated credential is usable here rather
|
||||
// than optimistically inserting it — a credential edited from the command bar
|
||||
// may well belong to another project.
|
||||
const refetchScope = getCredentialFetchScope();
|
||||
if (refetchScope) {
|
||||
try {
|
||||
await credentialsStore.fetchUsableCredentials(refetchScope);
|
||||
} catch {
|
||||
// Fall through with whatever the store already holds.
|
||||
}
|
||||
}
|
||||
|
||||
const current = selected.value[credentialType];
|
||||
let credentialsOfType: ICredentialsResponse[] = [];
|
||||
if (props.showAll) {
|
||||
|
|
@ -513,11 +538,6 @@ onMounted(() => {
|
|||
|
||||
ndvEventBus.on('credential.createNew', onCreateAndAssignNewCredential);
|
||||
|
||||
const scope = props.skipCredentialsFetch ? undefined : getCredentialFetchScope();
|
||||
if (scope) {
|
||||
void credentialsStore.fetchAllCredentialsForWorkflow(scope);
|
||||
}
|
||||
|
||||
void aiGateway.fetchConfig();
|
||||
void aiGateway.fetchWallet();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { ICredentialsResponse } from '../credentials.types';
|
||||
import * as credentialsApi from '../credentials.api';
|
||||
import { useCredentialsStore } from '../credentials.store';
|
||||
import { useCredentialOAuth } from './useCredentialOAuth';
|
||||
|
||||
const mockRootStore = {
|
||||
restApiContext: { baseUrl: 'http://localhost:5678', sessionId: 'test-session' },
|
||||
baseUrl: 'http://localhost:5678',
|
||||
urlBaseEditor: 'http://localhost:5678',
|
||||
};
|
||||
|
||||
const { useRootStore } = vi.hoisted(() => ({
|
||||
useRootStore: vi.fn(() => mockRootStore),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({ useRootStore }));
|
||||
|
||||
vi.mock('@n8n/composables/useTelemetry', () => ({
|
||||
useTelemetry: () => ({ track: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/composables/useToast', () => ({
|
||||
useToast: () => ({ showError: vi.fn(), showMessage: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('./oauthCallback', () => ({
|
||||
getTrustedOAuthOrigins: () => ['http://localhost:5678'],
|
||||
hasOAuthTokenData: vi.fn(() => false),
|
||||
waitForOAuthCallback: vi.fn(async () => 'success'),
|
||||
}));
|
||||
|
||||
vi.mock('../credentials.api');
|
||||
vi.mock('../credentials.ee.api');
|
||||
|
||||
const credential = (overrides: Partial<ICredentialsResponse> = {}): ICredentialsResponse =>
|
||||
mock<ICredentialsResponse>({
|
||||
id: 'new-cred',
|
||||
name: 'A Google account',
|
||||
type: 'oAuth2Api',
|
||||
isResolvable: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('useCredentialOAuth', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setActivePinia(createPinia());
|
||||
|
||||
vi.stubGlobal(
|
||||
'open',
|
||||
vi.fn(() => ({ location: { href: '' }, close: vi.fn() })),
|
||||
);
|
||||
vi.mocked(credentialsApi.oAuth2CredentialAuthorize).mockResolvedValue(
|
||||
'https://example.com/authorize',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('authorizeNewCredential', () => {
|
||||
it('makes the connected credential available to the scoped picker', async () => {
|
||||
const store = useCredentialsStore();
|
||||
const connected = credential();
|
||||
|
||||
// The picker starts on a scope that does not know the credential yet.
|
||||
vi.mocked(credentialsApi.getUsableCredentials).mockResolvedValue([]);
|
||||
await store.fetchUsableCredentials({ workflowId: 'wf-1' });
|
||||
expect(store.getUsableCredentialByType('oAuth2Api')).toEqual([]);
|
||||
|
||||
vi.mocked(credentialsApi.getUsableCredentials).mockResolvedValue([connected]);
|
||||
const success = await useCredentialOAuth().authorizeNewCredential(connected);
|
||||
|
||||
expect(success).toBe(true);
|
||||
expect(credentialsApi.getUsableCredentials).toHaveBeenLastCalledWith(
|
||||
mockRootStore.restApiContext,
|
||||
{ workflowId: 'wf-1' },
|
||||
);
|
||||
expect(store.getUsableCredentialByType('oAuth2Api')).toEqual([connected]);
|
||||
});
|
||||
|
||||
it('leaves the slice untouched when no scoped fetch has happened', async () => {
|
||||
const store = useCredentialsStore();
|
||||
const connected = credential();
|
||||
|
||||
await useCredentialOAuth().authorizeNewCredential(connected);
|
||||
|
||||
expect(credentialsApi.getUsableCredentials).not.toHaveBeenCalled();
|
||||
expect(store.hasFetchedUsableCredentials).toBe(false);
|
||||
// The flat map still learns about the credential, as it did before.
|
||||
expect(store.getCredentialById('new-cred')?.id).toBe('new-cred');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -318,6 +318,17 @@ export function useCredentialOAuth() {
|
|||
* Authorize a credential that was just created. Keeps it out of the store
|
||||
* until OAuth succeeds and removes it when authorization is not completed.
|
||||
*/
|
||||
/**
|
||||
* Publishes a credential the user just connected. `upsertCredential` only reaches
|
||||
* the flat map, so the picker — which reads the scope-narrowed slice — would not
|
||||
* offer the credential until the next scoped fetch. Ask the server rather than
|
||||
* inserting locally: only it can say whether the credential is usable here.
|
||||
*/
|
||||
async function publishConnectedCredential(credential: ICredentialsResponse): Promise<void> {
|
||||
credentialsStore.upsertCredential(credential);
|
||||
await credentialsStore.refreshUsableCredentials();
|
||||
}
|
||||
|
||||
async function authorizeNewCredential(
|
||||
credential: ICredentialsResponse,
|
||||
options: OAuthAuthorizationOptions = {},
|
||||
|
|
@ -329,7 +340,7 @@ export function useCredentialOAuth() {
|
|||
try {
|
||||
success = await authorize(credential, controller.signal, options);
|
||||
if (success) {
|
||||
credentialsStore.upsertCredential(credential);
|
||||
await publishConnectedCredential(credential);
|
||||
}
|
||||
return success;
|
||||
} finally {
|
||||
|
|
@ -427,7 +438,7 @@ export function useCredentialOAuth() {
|
|||
telemetry.track('User saved credentials', trackProperties);
|
||||
|
||||
if (success) {
|
||||
credentialsStore.upsertCredential(credential);
|
||||
await publishConnectedCredential(credential);
|
||||
|
||||
return credential;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,7 +119,9 @@ describe('useNodeCredentialOptions', () => {
|
|||
slackApi: slackApiType,
|
||||
slackOAuth2Api: slackOAuth2ApiType,
|
||||
};
|
||||
credentialsStore.state.credentials = {
|
||||
// The picker reads the slice the scoped fetch writes, not the flat map.
|
||||
credentialsStore.hasFetchedUsableCredentials = true;
|
||||
credentialsStore.usableCredentials = {
|
||||
'token-cred': createCredential({
|
||||
id: 'token-cred',
|
||||
name: 'Team Slack Token',
|
||||
|
|
@ -196,7 +198,7 @@ describe('useNodeCredentialOptions', () => {
|
|||
}) as INodeTypeDescription,
|
||||
);
|
||||
credentialsStore.state.credentialTypes.httpBasicAuth = httpBasicAuth;
|
||||
credentialsStore.state.credentials['basic-auth-cred'] = createCredential({
|
||||
credentialsStore.usableCredentials['basic-auth-cred'] = createCredential({
|
||||
id: 'basic-auth-cred',
|
||||
name: 'Webhook Basic Auth',
|
||||
type: 'httpBasicAuth',
|
||||
|
|
@ -218,7 +220,7 @@ describe('useNodeCredentialOptions', () => {
|
|||
});
|
||||
|
||||
it('excludes instance credentials from node options', () => {
|
||||
credentialsStore.state.credentials['instance-cred'] = createCredential({
|
||||
credentialsStore.usableCredentials['instance-cred'] = createCredential({
|
||||
id: 'instance-cred',
|
||||
name: 'Instance Slack Token',
|
||||
type: 'slackApi',
|
||||
|
|
@ -232,6 +234,60 @@ describe('useNodeCredentialOptions', () => {
|
|||
).not.toContain('instance-cred');
|
||||
});
|
||||
|
||||
it('ignores credentials the scoped fetch left out, even when the flat map holds them', () => {
|
||||
credentialsStore.state.credentials = {
|
||||
'personal-cred': createCredential({
|
||||
id: 'personal-cred',
|
||||
name: 'Personal Slack Token',
|
||||
type: 'slackApi',
|
||||
}),
|
||||
};
|
||||
|
||||
const { credentialTypesNodeDescriptionDisplayed } = setupOptions('slackApi');
|
||||
|
||||
expect(
|
||||
credentialTypesNodeDescriptionDisplayed.value[0].options.map((option) => option.id),
|
||||
).toEqual(['token-cred']);
|
||||
});
|
||||
|
||||
it('reports a configured out-of-scope credential as missing once the scope is fetched', () => {
|
||||
const nodeWithForeignCredential = computed(
|
||||
() =>
|
||||
({
|
||||
...slackNode,
|
||||
credentials: { slackApi: { id: 'personal-cred', name: 'Personal Slack Token' } },
|
||||
}) as INodeUi,
|
||||
);
|
||||
const { isCredentialExisting } = useNodeCredentialOptions(
|
||||
nodeWithForeignCredential,
|
||||
computed(() => slackNodeType),
|
||||
'slackApi',
|
||||
);
|
||||
|
||||
expect(isCredentialExisting(slackNodeType.credentials[0])).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a configured credential as existing until the scope has been fetched', () => {
|
||||
// Reporting it missing before the first fetch flashes "credential unavailable"
|
||||
// and raises an NDV credential issue that isn't one.
|
||||
credentialsStore.hasFetchedUsableCredentials = false;
|
||||
credentialsStore.usableCredentials = {};
|
||||
const nodeWithCredential = computed(
|
||||
() =>
|
||||
({
|
||||
...slackNode,
|
||||
credentials: { slackApi: { id: 'token-cred', name: 'Team Slack Token' } },
|
||||
}) as INodeUi,
|
||||
);
|
||||
const { isCredentialExisting } = useNodeCredentialOptions(
|
||||
nodeWithCredential,
|
||||
computed(() => slackNodeType),
|
||||
'slackApi',
|
||||
);
|
||||
|
||||
expect(isCredentialExisting(slackNodeType.credentials[0])).toBe(true);
|
||||
});
|
||||
|
||||
it('disables mixed credential behavior when override is set', () => {
|
||||
const slackApiDescription = slackNodeType.credentials[0];
|
||||
const { showMixedCredentials } = setupOptions('slackApi');
|
||||
|
|
|
|||
|
|
@ -146,6 +146,9 @@ export function useNodeCredentialOptions(
|
|||
// Gateway-managed credentials have no real DB record but are properly configured
|
||||
if (credential?.__aiGatewayManaged) return true;
|
||||
if (!credential?.id) return false;
|
||||
// Until the scoped fetch lands there is nothing to match against, and reporting
|
||||
// a configured credential as missing raises a credential issue that isn't one.
|
||||
if (!credentialsStore.hasFetchedUsableCredentials) return true;
|
||||
const options = getCredentialOptions([credentialType.name]);
|
||||
return !!options.find((option: ICredentialsResponse) => option.id === credential.id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import type { ICredentialsDecryptedResponse, ICredentialsResponse } from './credentials.types';
|
||||
import type {
|
||||
CredentialFetchScope,
|
||||
ICredentialsDecryptedResponse,
|
||||
ICredentialsResponse,
|
||||
} from './credentials.types';
|
||||
import type { IRestApiContext } from '@n8n/rest-api-client';
|
||||
import { makeRestApiRequest } from '@n8n/rest-api-client';
|
||||
import { sleep } from '@n8n/utils/sleep';
|
||||
|
|
@ -73,9 +77,9 @@ export async function getAllCredentials(
|
|||
});
|
||||
}
|
||||
|
||||
export async function getAllCredentialsForWorkflow(
|
||||
export async function getUsableCredentials(
|
||||
context: IRestApiContext,
|
||||
options: { workflowId: string } | { projectId: string },
|
||||
options: CredentialFetchScope,
|
||||
): Promise<ICredentialsResponse[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/credentials/for-workflow', {
|
||||
...options,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { INodeUi } from '@/Interface';
|
||||
import type {
|
||||
CredentialFetchScope,
|
||||
ICredentialMap,
|
||||
ICredentialsDecryptedResponse,
|
||||
ICredentialsResponse,
|
||||
|
|
@ -38,9 +39,38 @@ const TYPES_WITH_DEFAULT_NAME = ['httpBasicAuth', 'oAuth2Api', 'httpDigestAuth',
|
|||
|
||||
export type CredentialsStore = ReturnType<typeof useCredentialsStore>;
|
||||
|
||||
export type { CredentialFetchScope };
|
||||
|
||||
const scopeKey = (scope: CredentialFetchScope): string =>
|
||||
'workflowId' in scope ? `workflow:${scope.workflowId}` : `project:${scope.projectId}`;
|
||||
|
||||
export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
|
||||
const state = ref<ICredentialsState>({ credentialTypes: {}, credentials: {} });
|
||||
|
||||
/**
|
||||
* Credentials the backend says are usable in the workflow/project currently open.
|
||||
* Kept apart from `state.credentials`, which any of ~20 unscoped
|
||||
* `fetchAllCredentials` callers can replace while a canvas is open — the last
|
||||
* fetch to resolve would otherwise own the credential picker.
|
||||
*/
|
||||
const usableCredentials = ref<ICredentialMap>({});
|
||||
/**
|
||||
* An unfetched slice reads as empty, never as a fallback to the flat map —
|
||||
* falling back is the bug. Callers that must not act on "no credentials yet"
|
||||
* check this.
|
||||
*/
|
||||
const hasFetchedUsableCredentials = ref(false);
|
||||
/** The scope the slice currently holds, so it can be refreshed and invalidated. */
|
||||
const usableCredentialsScope = ref<CredentialFetchScope | null>(null);
|
||||
/** Bumped per scoped fetch, so only the most recent one publishes its response. */
|
||||
let usableCredentialsRequestId = 0;
|
||||
|
||||
const clearUsableCredentials = () => {
|
||||
usableCredentials.value = {};
|
||||
usableCredentialsScope.value = null;
|
||||
hasFetchedUsableCredentials.value = false;
|
||||
};
|
||||
|
||||
type CredentialTestStatus = 'pending' | 'success' | 'error';
|
||||
const credentialTestResults = ref(new Map<string, CredentialTestStatus>());
|
||||
|
||||
|
|
@ -89,19 +119,17 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
|
|||
});
|
||||
|
||||
const allUsableCredentialsByType = computed(() => {
|
||||
const credentials = allCredentials.value;
|
||||
const types = allCredentialTypes.value;
|
||||
const byType: { [type: string]: ICredentialsResponse[] } = {};
|
||||
|
||||
return types.reduce(
|
||||
(accu: { [type: string]: ICredentialsResponse[] }, type: ICredentialType) => {
|
||||
accu[type.name] = credentials.filter((cred: ICredentialsResponse) => {
|
||||
return cred.type === type.name;
|
||||
});
|
||||
for (const credential of Object.values(usableCredentials.value)) {
|
||||
(byType[credential.type] ??= []).push(credential);
|
||||
}
|
||||
|
||||
return accu;
|
||||
},
|
||||
{},
|
||||
);
|
||||
for (const credentials of Object.values(byType)) {
|
||||
credentials.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
return byType;
|
||||
});
|
||||
|
||||
const allUsableCredentialsForNode = computed(() => {
|
||||
|
|
@ -110,7 +138,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
|
|||
const nodeType = useNodeTypesStore().getNodeType(node.type, node.typeVersion);
|
||||
if (nodeType?.credentials) {
|
||||
nodeType.credentials.forEach((cred) => {
|
||||
credentials = credentials.concat(allUsableCredentialsByType.value[cred.name]);
|
||||
credentials = credentials.concat(allUsableCredentialsByType.value[cred.name] ?? []);
|
||||
});
|
||||
}
|
||||
return credentials.sort((a, b) => {
|
||||
|
|
@ -326,17 +354,57 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
|
|||
return credentials;
|
||||
};
|
||||
|
||||
const fetchAllCredentialsForWorkflow = async (
|
||||
options: { workflowId: string } | { projectId: string },
|
||||
const fetchUsableCredentials = async (
|
||||
options: CredentialFetchScope,
|
||||
): Promise<ICredentialsResponse[]> => {
|
||||
const credentials = await credentialsApi.getAllCredentialsForWorkflow(
|
||||
const requestedScope = scopeKey(options);
|
||||
// Opening another workflow or project invalidates the slice up front: while the
|
||||
// new scope is in flight consumers must read "not fetched yet", never the
|
||||
// previous scope's credentials.
|
||||
if (usableCredentialsScope.value && scopeKey(usableCredentialsScope.value) !== requestedScope) {
|
||||
clearUsableCredentials();
|
||||
}
|
||||
const requestId = ++usableCredentialsRequestId;
|
||||
|
||||
const credentials = await credentialsApi.getUsableCredentials(
|
||||
rootStore.restApiContext,
|
||||
options,
|
||||
);
|
||||
// The flat map keeps replace semantics for its existing consumers; the slice is
|
||||
// what the credential picker reads.
|
||||
setCredentials(credentials);
|
||||
|
||||
// Only the newest request publishes. Several scoped fetches can be in flight at
|
||||
// once — a mount racing the refresh a quick connect triggers, say — and an older
|
||||
// response landing last would reinstate a list we already know is out of date,
|
||||
// whether or not it was fetched for the same scope.
|
||||
if (requestId !== usableCredentialsRequestId) {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
usableCredentials.value = credentials.reduce((accu: ICredentialMap, cred) => {
|
||||
if (cred.id) {
|
||||
accu[cred.id] = cred;
|
||||
}
|
||||
return accu;
|
||||
}, {});
|
||||
usableCredentialsScope.value = options;
|
||||
hasFetchedUsableCredentials.value = true;
|
||||
return credentials;
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-reads the slice for the scope it was last fetched for. Flows that create a
|
||||
* credential outside a scoped fetch (OAuth quick connect) call this instead of
|
||||
* inserting locally: only the server can say whether the new credential is usable
|
||||
* in the scope currently open.
|
||||
*/
|
||||
const refreshUsableCredentials = async (): Promise<void> => {
|
||||
const scope = usableCredentialsScope.value;
|
||||
if (!scope) return;
|
||||
await fetchUsableCredentials(scope);
|
||||
};
|
||||
|
||||
const getCredentialData = async ({
|
||||
id,
|
||||
}: {
|
||||
|
|
@ -548,6 +616,9 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
|
|||
|
||||
return {
|
||||
state,
|
||||
usableCredentials,
|
||||
hasFetchedUsableCredentials,
|
||||
refreshUsableCredentials,
|
||||
credentialTestResults,
|
||||
isCredentialTestedOk,
|
||||
isCredentialTestPending,
|
||||
|
|
@ -576,7 +647,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
|
|||
upsertCredential,
|
||||
fetchCredentialTypes,
|
||||
fetchAllCredentials,
|
||||
fetchAllCredentialsForWorkflow,
|
||||
fetchUsableCredentials,
|
||||
createNewCredential,
|
||||
updateCredential,
|
||||
getCredentialData,
|
||||
|
|
|
|||
|
|
@ -74,3 +74,6 @@ export interface ICredentialsState {
|
|||
export interface IShareCredentialsPayload {
|
||||
shareWithIds: string[];
|
||||
}
|
||||
|
||||
/** What the picker's credential list is narrowed to: an open workflow, else a project. */
|
||||
export type CredentialFetchScope = { workflowId: string } | { projectId: string };
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ describe('getAutoSelectedCredential', () => {
|
|||
});
|
||||
|
||||
it('picks the most recently updated usable credential of the node type', () => {
|
||||
credentialsStore.state.credentials = {
|
||||
credentialsStore.usableCredentials = {
|
||||
older: createCredential({
|
||||
id: 'older',
|
||||
name: 'Older',
|
||||
|
|
@ -93,7 +93,7 @@ describe('getAutoSelectedCredential', () => {
|
|||
});
|
||||
|
||||
it('returns undefined when the node already has a credential set', () => {
|
||||
credentialsStore.state.credentials = { older: createCredential({ id: 'older' }) };
|
||||
credentialsStore.usableCredentials = { older: createCredential({ id: 'older' }) };
|
||||
|
||||
const node = createNode({
|
||||
credentials: { openAiApi: { id: 'older', name: 'Older' } },
|
||||
|
|
@ -103,7 +103,16 @@ describe('getAutoSelectedCredential', () => {
|
|||
});
|
||||
|
||||
it('returns undefined when no usable credentials of the required type exist', () => {
|
||||
credentialsStore.state.credentials = {};
|
||||
credentialsStore.usableCredentials = {};
|
||||
|
||||
expect(getAutoSelectedCredential(createNode())).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores credentials the scoped fetch left out of the usable slice', () => {
|
||||
// Paste/duplicate into a project must not auto-assign a credential that
|
||||
// project cannot use, which the backend would then reject on save.
|
||||
credentialsStore.state.credentials = { personal: createCredential({ id: 'personal' }) };
|
||||
credentialsStore.usableCredentials = {};
|
||||
|
||||
expect(getAutoSelectedCredential(createNode())).toBeUndefined();
|
||||
});
|
||||
|
|
@ -112,7 +121,7 @@ describe('getAutoSelectedCredential', () => {
|
|||
nodeTypesStore.setNodeTypes([
|
||||
{ ...openAiNodeType, name: 'n8n-nodes-base.noOp', credentials: undefined },
|
||||
]);
|
||||
credentialsStore.state.credentials = { older: createCredential() };
|
||||
credentialsStore.usableCredentials = { older: createCredential() };
|
||||
|
||||
const node = createNode({ type: 'n8n-nodes-base.noOp' });
|
||||
|
||||
|
|
@ -124,7 +133,7 @@ describe('getAutoSelectedCredential', () => {
|
|||
openAiApi: openAiApiCredentialType,
|
||||
slackApi: { ...openAiApiCredentialType, name: 'slackApi', displayName: 'Slack' },
|
||||
};
|
||||
credentialsStore.state.credentials = {
|
||||
credentialsStore.usableCredentials = {
|
||||
openai: createCredential({
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ describe('useWorkflowSetupState – node grouping', () => {
|
|||
credentialsStore.isCredentialTestPending = vi.fn().mockReturnValue(false);
|
||||
credentialsStore.getCredentialData = vi.fn().mockResolvedValue(undefined);
|
||||
credentialsStore.fetchAllCredentials = vi.fn().mockResolvedValue([]);
|
||||
credentialsStore.fetchAllCredentialsForWorkflow = vi.fn().mockResolvedValue([]);
|
||||
credentialsStore.fetchUsableCredentials = vi.fn().mockResolvedValue([]);
|
||||
nodeTypesStore.isTriggerNode = vi.fn().mockReturnValue(false);
|
||||
|
||||
mockGetNodeTypeDisplayableCredentials.mockReturnValue([]);
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ describe('useWorkflowSetupState', () => {
|
|||
credentialsStore.isCredentialTestPending = vi.fn().mockReturnValue(false);
|
||||
credentialsStore.getCredentialData = vi.fn().mockResolvedValue(undefined);
|
||||
credentialsStore.fetchAllCredentials = vi.fn().mockResolvedValue([]);
|
||||
credentialsStore.fetchAllCredentialsForWorkflow = vi.fn().mockResolvedValue([]);
|
||||
credentialsStore.fetchUsableCredentials = vi.fn().mockResolvedValue([]);
|
||||
nodeTypesStore.isTriggerNode = vi.fn().mockReturnValue(false);
|
||||
workflowExecutionStateStore = useWorkflowExecutionStateStore(
|
||||
createWorkflowDocumentId(workflowsStore.workflowId),
|
||||
|
|
|
|||
|
|
@ -401,7 +401,7 @@ onMounted(async () => {
|
|||
if (projectId) {
|
||||
await Promise.all([
|
||||
credentialsStore.fetchCredentialTypes(false),
|
||||
credentialsStore.fetchAllCredentialsForWorkflow({ projectId }),
|
||||
credentialsStore.fetchUsableCredentials({ projectId }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ describe('NodeToolSettingsContent', () => {
|
|||
credentialsStore.allCredentials = [];
|
||||
credentialsStore.setCredentials = vi.fn();
|
||||
credentialsStore.fetchCredentialTypes = vi.fn().mockResolvedValue(undefined);
|
||||
credentialsStore.fetchAllCredentialsForWorkflow = vi.fn().mockResolvedValue(undefined);
|
||||
credentialsStore.fetchUsableCredentials = vi.fn().mockResolvedValue(undefined);
|
||||
projectsStore.personalProject = { id: 'personal-project', name: 'Personal' } as never;
|
||||
projectsStore.setCurrentProject = vi.fn();
|
||||
projectsStore.fetchAndSetProject = vi.fn().mockResolvedValue(undefined);
|
||||
|
|
@ -344,7 +344,7 @@ describe('NodeToolSettingsContent', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(credentialsStore.fetchCredentialTypes).toHaveBeenCalledWith(false);
|
||||
expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({
|
||||
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({
|
||||
projectId: 'personal-project',
|
||||
});
|
||||
});
|
||||
|
|
@ -358,7 +358,7 @@ describe('NodeToolSettingsContent', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({
|
||||
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({
|
||||
projectId: 'personal-project',
|
||||
});
|
||||
});
|
||||
|
|
@ -376,7 +376,7 @@ describe('NodeToolSettingsContent', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(projectsStore.getPersonalProject).toHaveBeenCalled();
|
||||
expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({
|
||||
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({
|
||||
projectId: 'personal-project',
|
||||
});
|
||||
});
|
||||
|
|
@ -400,7 +400,7 @@ describe('NodeToolSettingsContent', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({
|
||||
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({
|
||||
projectId: 'personal-project',
|
||||
});
|
||||
});
|
||||
|
|
@ -427,7 +427,7 @@ describe('NodeToolSettingsContent', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({
|
||||
expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({
|
||||
projectId: 'team-project',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@ export class WorkflowMenu {
|
|||
return this.page.getByTestId('workflow-menu-item-push');
|
||||
}
|
||||
|
||||
getChangeOwnerItem(): Locator {
|
||||
return this.page.getByTestId('workflow-menu-item-change-owner');
|
||||
}
|
||||
|
||||
getUnpublishItem(): Locator {
|
||||
return this.page.getByTestId('workflow-menu-item-unpublish');
|
||||
}
|
||||
|
|
@ -75,6 +79,11 @@ export class WorkflowMenu {
|
|||
await this.getDuplicateItem().click();
|
||||
}
|
||||
|
||||
async openChangeOwner(): Promise<void> {
|
||||
await this.open();
|
||||
await this.getChangeOwnerItem().click();
|
||||
}
|
||||
|
||||
async clickDelete(): Promise<void> {
|
||||
await this.getDeleteItem().click();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { test, expect } from '../../../fixtures/base';
|
||||
|
||||
const TEST_API_KEY = 'test-api-key';
|
||||
|
||||
test.describe(
|
||||
'Node Credential Picker Scoping',
|
||||
{
|
||||
annotation: [{ type: 'owner', description: 'Identity & Access' }],
|
||||
},
|
||||
() => {
|
||||
test('offers only credentials the project can use, across an unscoped credential fetch', async ({
|
||||
n8n,
|
||||
api,
|
||||
}) => {
|
||||
// The picker asks the backend which credentials this workflow may use, but
|
||||
// several everyday actions fetch every credential the *user* can read. Those
|
||||
// answers used to share one store slot, so the last fetch to land owned the
|
||||
// dropdown — offering credentials the workflow cannot use, which the backend
|
||||
// then rejects or silently discards on save.
|
||||
const personalCredName = `Personal Notion ${nanoid()}`;
|
||||
await api.credentials.createCredential({
|
||||
name: personalCredName,
|
||||
type: 'notionApi',
|
||||
data: { apiKey: TEST_API_KEY },
|
||||
});
|
||||
|
||||
const project = await api.projects.createProject(`Team ${nanoid()}`);
|
||||
const teamCredName = `Team Notion ${nanoid()}`;
|
||||
const teamCred = await api.credentials.createCredential({
|
||||
name: teamCredName,
|
||||
type: 'notionApi',
|
||||
data: { apiKey: TEST_API_KEY },
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
const workflow = await api.workflows.createWorkflow(
|
||||
{
|
||||
name: `Credential scope ${nanoid()}`,
|
||||
nodes: [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [250, 300],
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'Notion',
|
||||
type: 'n8n-nodes-base.notion',
|
||||
typeVersion: 2.2,
|
||||
position: [450, 300],
|
||||
parameters: { resource: 'database', operation: 'get' },
|
||||
credentials: {
|
||||
notionApi: { id: teamCred.id, name: teamCredName },
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Manual Trigger': {
|
||||
main: [[{ node: 'Notion', type: 'main', index: 0 }]],
|
||||
},
|
||||
},
|
||||
active: false,
|
||||
settings: {},
|
||||
},
|
||||
project.id,
|
||||
);
|
||||
|
||||
await n8n.navigate.toWorkflow(workflow.id);
|
||||
await n8n.canvas.openNode('Notion');
|
||||
await expect(n8n.ndv.container).toBeVisible();
|
||||
|
||||
await n8n.ndv.getNodeCredentialsSelect().click();
|
||||
await expect(n8n.ndv.credentials.getOptionByText(teamCredName)).toBeVisible();
|
||||
await expect(n8n.ndv.credentials.getOptionByText(personalCredName)).toBeHidden();
|
||||
await n8n.page.keyboard.press('Escape');
|
||||
await n8n.ndv.close();
|
||||
|
||||
// "Change owner" loads every credential the user can read, personal ones
|
||||
// included, without leaving the workflow. The picker must not widen because
|
||||
// of it.
|
||||
const unscopedFetch = n8n.page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === '/rest/credentials' &&
|
||||
response.request().method() === 'GET',
|
||||
);
|
||||
await n8n.workflowMenu.openChangeOwner();
|
||||
await unscopedFetch;
|
||||
await n8n.page.keyboard.press('Escape');
|
||||
|
||||
await n8n.canvas.openNode('Notion');
|
||||
await n8n.ndv.getNodeCredentialsSelect().click();
|
||||
await expect(n8n.ndv.credentials.getOptionByText(teamCredName)).toBeVisible();
|
||||
await expect(n8n.ndv.credentials.getOptionByText(personalCredName)).toBeHidden();
|
||||
});
|
||||
},
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user