fix(editor): Fix non-project credentials sometimes appearing as options (#36715)

This commit is contained in:
James Martin 2026-08-24 13:40:47 +00:00 committed by GitHub
parent e75bf3e052
commit ffd2b35595
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 765 additions and 88 deletions

View File

@ -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,
})),
}));

View File

@ -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) {

View File

@ -113,7 +113,7 @@ export function useWorkflowInitialization() {
options = { projectId };
}
await credentialsStore.fetchAllCredentialsForWorkflow(options);
await credentialsStore.fetchUsableCredentials(options);
}
/**

View File

@ -830,7 +830,7 @@ async function loadCredentials() {
options = { projectId };
}
await credentialsStore.fetchAllCredentialsForWorkflow(options);
await credentialsStore.fetchUsableCredentials(options);
}
/**

View File

@ -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();
});

View File

@ -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([]),
}),
}));

View File

@ -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,

View File

@ -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(),
}),
}));

View File

@ -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),
}),
}));

View File

@ -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(),
}),
}));

View File

@ -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(

View File

@ -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(

View File

@ -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>();

View File

@ -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,
});

View File

@ -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;

View File

@ -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;

View File

@ -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),

View File

@ -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;

View File

@ -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();

View File

@ -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),
]);

View File

@ -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',
});
});

View File

@ -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(),
]);

View File

@ -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(),
}),
}));

View File

@ -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();

View File

@ -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.

View File

@ -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();

View File

@ -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');
});
});
});

View File

@ -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;
}

View File

@ -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');

View File

@ -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);
}

View File

@ -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,

View File

@ -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,

View File

@ -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 };

View File

@ -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',

View File

@ -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([]);

View File

@ -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),

View File

@ -401,7 +401,7 @@ onMounted(async () => {
if (projectId) {
await Promise.all([
credentialsStore.fetchCredentialTypes(false),
credentialsStore.fetchAllCredentialsForWorkflow({ projectId }),
credentialsStore.fetchUsableCredentials({ projectId }),
]);
}
});

View File

@ -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',
});
});

View File

@ -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();
}

View File

@ -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();
});
},
);