fix(editor): Normalize credential data from source control (#15434)

This commit is contained in:
Raúl Gómez Morales
2025-05-16 11:09:47 +02:00
committed by GitHub
parent e68149bbc7
commit 840a3bee4b
2 changed files with 250 additions and 3 deletions

View File

@@ -37,8 +37,8 @@ import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { Project, ProjectSharingData } from '@/types/projects.types';
import { assert } from '@n8n/utils/assert';
import type { IMenuItem } from '@n8n/design-system';
import { assert } from '@n8n/utils/assert';
import { createEventBus } from '@n8n/utils/event-bus';
import { useExternalHooks } from '@/composables/useExternalHooks';
@@ -476,6 +476,19 @@ function getCredentialProperties(name: string): INodeProperties[] {
return combineProperties;
}
/**
*
* We might get credential with empty parameters from source-control
* which breaks our types and Fe checks
*/
function removePropertiesWithEmptyStrings<T extends { [key: string]: unknown }>(data: T): T {
const copy = structuredClone(data);
Object.entries(copy).forEach(([key, value]) => {
if (value === '') delete copy[key];
});
return copy;
}
async function loadCurrentCredential() {
credentialId.value = props.activeId ?? '';
@@ -494,7 +507,10 @@ async function loadCurrentCredential() {
currentCredential.value = currentCredentials;
credentialData.value = (currentCredentials.data as ICredentialDataDecryptedObject) || {};
credentialData.value = removePropertiesWithEmptyStrings(
(currentCredentials.data as ICredentialDataDecryptedObject) || {},
);
if (currentCredentials.sharedWithProjects) {
credentialData.value = {
...credentialData.value,

View File

@@ -2,9 +2,182 @@ import { createComponentRenderer } from '@/__tests__/render';
import CredentialEdit from '@/components/CredentialEdit/CredentialEdit.vue';
import { createTestingPinia } from '@pinia/testing';
import { CREDENTIAL_EDIT_MODAL_KEY, STORES } from '@/constants';
import { cleanupAppModals, createAppModals, retry } from '@/__tests__/utils';
import { cleanupAppModals, createAppModals, retry, mockedStore } from '@/__tests__/utils';
import { useCredentialsStore } from '@/stores/credentials.store';
import type { ICredentialsResponse } from '@/Interface';
import { within } from '@testing-library/vue';
import type { ICredentialType } from 'n8n-workflow';
const oAuth2Api: ICredentialType = {
name: 'oAuth2Api',
displayName: 'OAuth2 API',
documentationUrl: 'httpRequest',
genericAuth: true,
properties: [
{
displayName: 'Grant Type',
name: 'grantType',
type: 'options',
options: [
{
name: 'Authorization Code',
value: 'authorizationCode',
},
{
name: 'Client Credentials',
value: 'clientCredentials',
},
{
name: 'PKCE',
value: 'pkce',
},
],
default: 'authorizationCode',
},
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'string',
displayOptions: {
show: {
grantType: ['authorizationCode', 'pkce'],
},
},
default: '',
required: true,
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'string',
default: '',
required: true,
},
{
displayName: 'Client ID',
name: 'clientId',
type: 'string',
default: '',
required: true,
},
{
displayName: 'Client Secret',
name: 'clientSecret',
type: 'string',
typeOptions: {
password: true,
},
default: '',
required: true,
},
{
displayName: 'Scope',
name: 'scope',
type: 'string',
default: '',
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'string',
displayOptions: {
show: {
grantType: ['authorizationCode', 'pkce'],
},
},
default: '',
description:
'For some services additional query parameters have to be set which can be defined here',
placeholder: 'access_type=offline',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'Body',
value: 'body',
description: 'Send credentials in body',
},
{
name: 'Header',
value: 'header',
description: 'Send credentials as Basic Auth header',
},
],
default: 'header',
},
{
displayName: 'Ignore SSL Issues (Insecure)',
name: 'ignoreSSLIssues',
type: 'boolean',
default: false,
doNotInherit: true,
},
],
iconUrl: 'icons/n8n-nodes-base/dist/nodes/GraphQL/graphql.png',
supportedNodes: ['n8n-nodes-base.graphql', 'n8n-nodes-base.httpRequest'],
};
const googleOAuth2Api: ICredentialType = {
name: 'googleOAuth2Api',
extends: ['oAuth2Api'],
displayName: 'Google OAuth2 API',
documentationUrl: 'google/oauth-generic',
properties: [
{
displayName: 'Grant Type',
name: 'grantType',
type: 'hidden',
default: 'authorizationCode',
},
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden',
default: 'https://accounts.google.com/o/oauth2/v2/auth',
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden',
default: 'https://oauth2.googleapis.com/token',
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden',
default: 'access_type=offline&prompt=consent',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden',
default: 'body',
},
],
iconUrl: 'icons/n8n-nodes-base/dist/credentials/icons/Google.svg',
supportedNodes: [],
};
const googleBigQueryOAuth2Api: ICredentialType = {
name: 'googleBigQueryOAuth2Api',
extends: ['googleOAuth2Api'],
displayName: 'Google BigQuery OAuth2 API',
documentationUrl: 'google/oauth-single-service',
properties: [
{
displayName: 'Scope',
name: 'scope',
type: 'hidden',
default:
'https://www.googleapis.com/auth/bigquery https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/drive',
},
],
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Google/BigQuery/googleBigQuery.svg',
supportedNodes: ['n8n-nodes-base.googleBigQuery'],
};
vi.mock('@/permissions', () => ({
getResourcePermissions: vi.fn(() => ({
@@ -123,4 +296,62 @@ describe('CredentialEdit', () => {
await retry(() => expect(queryByText('Connection')).not.toBeInTheDocument());
await retry(() => expect(queryByText('Sharing')).not.toBeInTheDocument());
});
test.each([
{
case: 'valid credential',
data: {
clientId: 'client_id',
clientSecret: '__n8n_EMPTY_VALUE_7b1af746-3729-4c60-9b9b-e08eb29e58da',
},
},
{
case: 'malformed credential from source-control',
data: {
grantType: '',
authUrl: '',
accessTokenUrl: '',
clientId: 'client_id',
clientSecret: '__n8n_EMPTY_VALUE_7b1af746-3729-4c60-9b9b-e08eb29e58da',
scope: '',
authQueryParameters: '',
authentication: '',
},
},
])('should show Sign in with Google for $case data', async ({ data }) => {
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.getCredentialData.mockResolvedValueOnce({
// @ts-expect-error data is decrypted
data,
createdAt: '2025-05-13T10:45:45.588Z',
updatedAt: '2025-05-15T12:36:01.826Z',
id: '6ufuMkOc7bb3LyGV',
name: 'Google BigQuery account',
type: 'googleBigQueryOAuth2Api',
isManaged: false,
sharedWithProjects: [],
scopes: ['credential:update'],
oauthTokenData: false,
});
credentialsStore.state.credentialTypes = {
[oAuth2Api.name]: oAuth2Api,
[googleOAuth2Api.name]: googleOAuth2Api,
[googleBigQueryOAuth2Api.name]: googleBigQueryOAuth2Api,
};
const { getByTestId } = renderComponent({
props: {
activeId: '6ufuMkOc7bb3LyGV',
modalName: CREDENTIAL_EDIT_MODAL_KEY,
mode: 'edit',
},
});
await retry(() => expect(credentialsStore.getCredentialData).toHaveBeenCalled());
await retry(() => expect(getByTestId('credential-edit-dialog')).toBeInTheDocument());
expect(
within(getByTestId('credential-edit-dialog')).getByTestId('oauth-connect-button'),
).toBeInTheDocument();
});
});