[8.17] [ResponseOps][Connectors] Fix bug with OAuth form in the ServiceNow connector (#213658) (#213865)

# Backport

This will backport the following commits from `main` to `8.17`:
- [[ResponseOps][Connectors] Fix bug with OAuth form in the ServiceNow
connector (#213658)](https://github.com/elastic/kibana/pull/213658)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Christos
Nasikas","email":"christos.nasikas@elastic.co"},"sourceCommit":{"committedDate":"2025-03-10T18:53:48Z","message":"[ResponseOps][Connectors]
Fix bug with OAuth form in the ServiceNow connector (#213658)\n\n##
Summary\n\nThis PR fixes a bug where users could not create a ServiceNow
connector\nwith OAuth configuration. In addition to the fix, I decided
to improve\nthe error messages and show the callout to install our SN
applications\nonly on CORS errors. The rest of the errors will be shown
on a generic\nerror callout.\n\n<img width=\"1246\" alt=\"Screenshot
2025-03-08 at 1 54
56 PM\"\nsrc=\"https://github.com/user-attachments/assets/5dac9662-be9b-474a-a0ca-d6d1a14baa53\"\n/>\n<img
width=\"1248\" alt=\"Screenshot 2025-03-08 at 1 55
16 PM\"\nsrc=\"https://github.com/user-attachments/assets/fc548263-ebd3-4ce6-aac1-725236b626b5\"\n/>\n\n\nFixes:
https://github.com/elastic/kibana/issues/212790\n\n###
Checklist\n\nCheck the PR satisfies following conditions. \n\nReviewers
should verify this PR satisfies this list as well.\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n\n## Release
notes\nFix a bug with ServiceNow where users could not create the
connector\nfrom the UI form using
OAuth.","sha":"2839562b8a7c8016582cbb0d5fc35e2a71cdaccf","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["bug","release_note:fix","Team:ResponseOps","v9.0.0","Feature:Actions/ConnectorTypes","backport:version","v8.18.0","v9.1.0","v8.19.0","v8.16.6","v8.17.4"],"title":"[ResponseOps][Connectors]
Fix bug with OAuth form in the ServiceNow
connector","number":213658,"url":"https://github.com/elastic/kibana/pull/213658","mergeCommit":{"message":"[ResponseOps][Connectors]
Fix bug with OAuth form in the ServiceNow connector (#213658)\n\n##
Summary\n\nThis PR fixes a bug where users could not create a ServiceNow
connector\nwith OAuth configuration. In addition to the fix, I decided
to improve\nthe error messages and show the callout to install our SN
applications\nonly on CORS errors. The rest of the errors will be shown
on a generic\nerror callout.\n\n<img width=\"1246\" alt=\"Screenshot
2025-03-08 at 1 54
56 PM\"\nsrc=\"https://github.com/user-attachments/assets/5dac9662-be9b-474a-a0ca-d6d1a14baa53\"\n/>\n<img
width=\"1248\" alt=\"Screenshot 2025-03-08 at 1 55
16 PM\"\nsrc=\"https://github.com/user-attachments/assets/fc548263-ebd3-4ce6-aac1-725236b626b5\"\n/>\n\n\nFixes:
https://github.com/elastic/kibana/issues/212790\n\n###
Checklist\n\nCheck the PR satisfies following conditions. \n\nReviewers
should verify this PR satisfies this list as well.\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n\n## Release
notes\nFix a bug with ServiceNow where users could not create the
connector\nfrom the UI form using
OAuth.","sha":"2839562b8a7c8016582cbb0d5fc35e2a71cdaccf"}},"sourceBranch":"main","suggestedTargetBranches":["8.16","8.17"],"targetPullRequestStates":[{"branch":"9.0","label":"v9.0.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"url":"https://github.com/elastic/kibana/pull/213818","number":213818,"state":"OPEN"},{"branch":"8.18","label":"v8.18.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"url":"https://github.com/elastic/kibana/pull/213816","number":213816,"state":"OPEN"},{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/213658","number":213658,"mergeCommit":{"message":"[ResponseOps][Connectors]
Fix bug with OAuth form in the ServiceNow connector (#213658)\n\n##
Summary\n\nThis PR fixes a bug where users could not create a ServiceNow
connector\nwith OAuth configuration. In addition to the fix, I decided
to improve\nthe error messages and show the callout to install our SN
applications\nonly on CORS errors. The rest of the errors will be shown
on a generic\nerror callout.\n\n<img width=\"1246\" alt=\"Screenshot
2025-03-08 at 1 54
56 PM\"\nsrc=\"https://github.com/user-attachments/assets/5dac9662-be9b-474a-a0ca-d6d1a14baa53\"\n/>\n<img
width=\"1248\" alt=\"Screenshot 2025-03-08 at 1 55
16 PM\"\nsrc=\"https://github.com/user-attachments/assets/fc548263-ebd3-4ce6-aac1-725236b626b5\"\n/>\n\n\nFixes:
https://github.com/elastic/kibana/issues/212790\n\n###
Checklist\n\nCheck the PR satisfies following conditions. \n\nReviewers
should verify this PR satisfies this list as well.\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n\n## Release
notes\nFix a bug with ServiceNow where users could not create the
connector\nfrom the UI form using
OAuth.","sha":"2839562b8a7c8016582cbb0d5fc35e2a71cdaccf"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"url":"https://github.com/elastic/kibana/pull/213817","number":213817,"state":"OPEN"},{"branch":"8.16","label":"v8.16.6","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"8.17","label":"v8.17.4","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->
This commit is contained in:
Christos Nasikas 2025-03-11 13:22:51 +02:00 committed by GitHub
parent 5b945f74f5
commit f8ff8466dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 451 additions and 139 deletions

View file

@ -6,7 +6,7 @@
*/
import { httpServiceMock } from '@kbn/core/public/mocks';
import { getChoices, getAppInfo } from './api';
import { getChoices, getAppInfo, getOAuthToken } from './api';
import { ServiceNowActionConnector } from './types';
const choicesResponse = {
@ -123,6 +123,7 @@ describe('ServiceNow API', () => {
expect(res).toEqual(applicationInfoData.result);
expect(http.post).toHaveBeenCalledWith('/internal/actions/connector/_oauth_access_token', {
signal: abortCtrl.signal,
body: JSON.stringify({
type: 'jwt',
options: {
@ -160,6 +161,7 @@ describe('ServiceNow API', () => {
expect(res).toEqual(applicationInfoData.result);
expect(http.post).toHaveBeenCalledWith('/internal/actions/connector/_oauth_access_token', {
signal: abortCtrl.signal,
body: JSON.stringify({
type: 'jwt',
options: {
@ -247,4 +249,69 @@ describe('ServiceNow API', () => {
).rejects.toThrow('bad');
});
});
describe('getOAuthToken', () => {
it('should call the API correctly', async () => {
const abortCtrl = new AbortController();
http.post.mockResolvedValueOnce(oAuthResponse);
const res = await getOAuthToken({
signal: abortCtrl.signal,
connector: oAuthConnector,
http,
});
expect(res).toEqual(oAuthResponse);
expect(http.post).toHaveBeenCalledWith('/internal/actions/connector/_oauth_access_token', {
signal: abortCtrl.signal,
body: JSON.stringify({
type: 'jwt',
options: {
tokenUrl: 'https://example.com/oauth_token.do',
config: {
clientId: 'clientId',
userIdentifierValue: 'userIdentifierValue',
jwtKeyId: 'jwtKeyId',
},
secrets: { clientSecret: 'test', privateKey: 'test' },
},
}),
});
});
it('should construct the error correctly when body is defined', async () => {
expect.assertions(1);
const abortCtrl = new AbortController();
const error = new Error('my error message');
// @ts-expect-error
error.body = { statusCode: 400, error: 'body error', message: 'body error message' };
http.post.mockRejectedValueOnce(error);
await expect(() =>
getOAuthToken({
signal: abortCtrl.signal,
connector: basicAuthConnector,
http,
})
).rejects.toThrow('400 body error: body error message');
});
it('should construct the error correctly when body is undefined', async () => {
expect.assertions(1);
const abortCtrl = new AbortController();
const error = new Error('my error message');
http.post.mockRejectedValueOnce(error);
await expect(() =>
getOAuthToken({
signal: abortCtrl.signal,
connector: basicAuthConnector,
http,
})
).rejects.toThrow('my error message');
});
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { HttpSetup } from '@kbn/core/public';
import { HttpSetup, IHttpFetchError } from '@kbn/core/public';
import {
ActionTypeExecutorResult,
@ -59,37 +59,15 @@ export async function getAppInfo({
actionTypeId: string;
}): Promise<AppInfo | RESTApiError> {
const {
secrets: { username, password, clientSecret, privateKey, privateKeyPassword },
config: { isOAuth, apiUrl, clientId, userIdentifierValue, jwtKeyId },
secrets: { username, password },
config: { isOAuth, apiUrl },
} = connector;
const urlWithoutTrailingSlash = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl;
let authHeader = 'Basic ' + btoa(username + ':' + password);
if (isOAuth) {
const tokenResponse = await http.post<{ accessToken: string }>(
`${INTERNAL_BASE_ACTION_API_PATH}/connector/_oauth_access_token`,
{
body: JSON.stringify({
type: 'jwt',
options: {
tokenUrl: `${urlWithoutTrailingSlash}/oauth_token.do`,
config: {
clientId,
userIdentifierValue,
jwtKeyId,
},
secrets: {
clientSecret,
privateKey,
...(privateKeyPassword && { privateKeyPassword }),
},
},
}),
}
);
const { accessToken } = tokenResponse;
const { accessToken } = await getOAuthToken({ http, signal, connector });
authHeader = accessToken;
}
@ -112,3 +90,57 @@ export async function getAppInfo({
...data.result,
};
}
export async function getOAuthToken({
http,
signal,
connector,
}: {
http: HttpSetup;
signal: AbortSignal;
connector: ServiceNowActionConnector;
}): Promise<{ accessToken: string }> {
const {
secrets: { clientSecret, privateKey, privateKeyPassword },
config: { apiUrl, clientId, userIdentifierValue, jwtKeyId },
} = connector;
const urlWithoutTrailingSlash = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl;
try {
const res = await http.post<{ accessToken: string }>(
`${INTERNAL_BASE_ACTION_API_PATH}/connector/_oauth_access_token`,
{
signal,
body: JSON.stringify({
type: 'jwt',
options: {
tokenUrl: `${urlWithoutTrailingSlash}/oauth_token.do`,
config: {
clientId,
userIdentifierValue,
jwtKeyId,
},
secrets: {
clientSecret,
privateKey,
...(privateKeyPassword && { privateKeyPassword }),
},
},
}),
}
);
return res;
} catch (error) {
const err = error as IHttpFetchError<{ statusCode?: number; error?: string; message?: string }>;
const errorMessage = err.body?.message ?? err.message;
const hasBodyError = err.body?.statusCode && err.body?.error;
const finalMessage = hasBodyError
? `${err.body.statusCode} ${err.body.error}: ${errorMessage}`
: errorMessage;
throw new Error(finalMessage);
}
}

View file

@ -38,6 +38,7 @@ const OAuthComponent: React.FC<Props> = ({ isLoading, readOnly, pathPrefix = ''
],
}}
componentProps={{
id: 'clientId',
euiFieldProps: {
'data-test-subj': 'connector-servicenow-client-id-form-input',
readOnly,
@ -58,6 +59,7 @@ const OAuthComponent: React.FC<Props> = ({ isLoading, readOnly, pathPrefix = ''
],
}}
componentProps={{
id: 'userIdentifierValue',
euiFieldProps: {
'data-test-subj': 'connector-servicenow-user-identifier-form-input',
readOnly,
@ -78,6 +80,7 @@ const OAuthComponent: React.FC<Props> = ({ isLoading, readOnly, pathPrefix = ''
],
}}
componentProps={{
id: 'jwtKeyId',
euiFieldProps: {
'data-test-subj': 'connector-servicenow-jwt-key-id-form-input',
readOnly,
@ -98,6 +101,7 @@ const OAuthComponent: React.FC<Props> = ({ isLoading, readOnly, pathPrefix = ''
}}
component={PasswordField}
componentProps={{
id: 'clientSecret',
euiFieldProps: {
'data-test-subj': 'connector-servicenow-client-secret-form-input',
isLoading,
@ -118,6 +122,7 @@ const OAuthComponent: React.FC<Props> = ({ isLoading, readOnly, pathPrefix = ''
],
}}
componentProps={{
id: 'privateKey',
euiFieldProps: {
readOnly,
'data-test-subj': 'connector-servicenow-private-key-form-input',
@ -134,6 +139,7 @@ const OAuthComponent: React.FC<Props> = ({ isLoading, readOnly, pathPrefix = ''
}}
component={PasswordField}
componentProps={{
id: 'privateKeyPassword',
euiFieldProps: {
'data-test-subj': 'connector-servicenow-private-key-password-form-input',
isLoading,

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export class CORSError extends Error {
constructor(message: string) {
super(message);
this.name = 'CORSError';
}
}

View file

@ -48,6 +48,7 @@ const CredentialsComponent: React.FC<Props> = ({ readOnly, isLoading, isOAuth })
euiFieldProps: {
label: i18n.IS_OAUTH,
disabled: readOnly,
'data-test-subj': 'use-oauth-switch',
},
}}
/>

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ErrorCallout } from './error_callout';
describe('ErrorCallout', () => {
it('renders the callout', () => {
render(<ErrorCallout message={'My error message'} />);
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('My error message')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo } from 'react';
import { EuiSpacer, EuiCallOut } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
const ERROR_MESSAGE = i18n.translate(
'xpack.stackConnectors.components.serviceNow.errorCallout.errorTitle',
{
defaultMessage: 'Error',
}
);
interface Props {
message: string | null;
}
const ErrorCalloutComponent: React.FC<Props> = ({ message }) => {
return (
<>
<EuiSpacer size="s" />
<EuiCallOut
size="m"
iconType="warning"
data-test-subj="errorCallout"
color="danger"
title={ERROR_MESSAGE}
>
<p>{message}</p>
</EuiCallOut>
<EuiSpacer size="m" />
</>
);
};
export const ErrorCallout = memo(ErrorCalloutComponent);

View file

@ -5,11 +5,13 @@
* 2.0.
*/
import { CORSError } from './cors_error';
import {
isRESTApiError,
isFieldInvalid,
getConnectorDescriptiveTitle,
getSelectedConnectorIcon,
isCORSError,
} from './helpers';
import { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types';
@ -51,6 +53,17 @@ describe('helpers', () => {
});
});
describe('isCORSError', () => {
test('should return true if the error is CORSError', () => {
const error = new CORSError('cors error');
expect(isCORSError(error)).toBeTruthy();
});
test('should return false if there is no error', () => {
expect(isCORSError(new Error())).toBeFalsy();
});
});
describe('isFieldInvalid', () => {
test('should return true if the field is invalid', async () => {
expect(isFieldInvalid('description', ['required'])).toBeTruthy();

View file

@ -13,6 +13,7 @@ import {
IErrorObject,
} from '@kbn/triggers-actions-ui-plugin/public';
import { AppInfo, Choice, RESTApiError } from './types';
import { CORSError } from './cors_error';
export const DEFAULT_CORRELATION_ID = '{{rule.id}}:{{alert.id}}';
@ -25,6 +26,8 @@ export const isRESTApiError = (res: AppInfo | RESTApiError | undefined): res is
res != null &&
((res as RESTApiError).error != null || (res as RESTApiError).status === 'failure');
export const isCORSError = (error: unknown): error is CORSError => error instanceof CORSError;
export const isFieldInvalid = (
field: string | undefined | null,
error: string | IErrorObject | string[]

View file

@ -5,9 +5,8 @@
* 2.0.
*/
import React from 'react';
import React, { ReactNode } from 'react';
import { act, within, render, screen, waitFor } from '@testing-library/react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { ConnectorValidationFunc } from '@kbn/triggers-actions-ui-plugin/public/types';
import { useKibana } from '@kbn/triggers-actions-ui-plugin/public';
@ -15,7 +14,6 @@ import { updateActionConnector } from '@kbn/triggers-actions-ui-plugin/public/ap
import ServiceNowConnectorFields from './servicenow_connectors';
import { getAppInfo } from './api';
import { ConnectorFormTestProvider } from '../test_utils';
import { mount } from 'enzyme';
import userEvent from '@testing-library/user-event';
jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana');
@ -26,6 +24,13 @@ const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const getAppInfoMock = getAppInfo as jest.Mock;
const updateActionConnectorMock = updateActionConnector as jest.Mock;
type PreSubmitValidatorRes =
| {
message: ReactNode;
}
| undefined
| void;
describe('ServiceNowActionConnectorFields renders', () => {
const usesTableApiConnector = {
id: 'test',
@ -59,19 +64,19 @@ describe('ServiceNowActionConnectorFields renders', () => {
...usesTableApiConnector.config,
isOAuth: true,
usesTableApi: false,
clientId: 'test-id',
userIdentifierValue: 'email',
jwtKeyId: 'test-id',
clientId: 'test-client-id',
userIdentifierValue: 'test-user-identifier',
jwtKeyId: 'test-jwt-key-id',
},
secrets: {
clientSecret: 'secret',
privateKey: 'secret-key',
privateKeyPassword: 'secret-pass',
clientSecret: 'test-client-secret',
privateKey: 'secret-private-key',
privateKeyPassword: 'test-private-key-password',
},
};
test('alerting servicenow connector fields are rendered', () => {
const wrapper = mountWithIntl(
it('alerting servicenow connector fields are rendered', () => {
render(
<ConnectorFormTestProvider connector={usesTableApiConnector}>
<ServiceNowConnectorFields
readOnly={false}
@ -80,18 +85,14 @@ describe('ServiceNowActionConnectorFields renders', () => {
/>
</ConnectorFormTestProvider>
);
expect(
wrapper.find('[data-test-subj="connector-servicenow-username-form-input"]').length > 0
).toBeTruthy();
expect(wrapper.find('[data-test-subj="credentialsApiUrlFromInput"]').length > 0).toBeTruthy();
expect(
wrapper.find('[data-test-subj="connector-servicenow-password-form-input"]').length > 0
).toBeTruthy();
expect(screen.getByTestId('connector-servicenow-username-form-input')).toBeInTheDocument();
expect(screen.getByTestId('credentialsApiUrlFromInput')).toBeInTheDocument();
expect(screen.getByTestId('connector-servicenow-password-form-input')).toBeInTheDocument();
});
test('case specific servicenow connector fields is rendered', () => {
const wrapper = mountWithIntl(
it('case specific servicenow connector fields is rendered', () => {
render(
<ConnectorFormTestProvider connector={usesImportSetApiConnector}>
<ServiceNowConnectorFields
readOnly={false}
@ -101,10 +102,111 @@ describe('ServiceNowActionConnectorFields renders', () => {
</ConnectorFormTestProvider>
);
expect(wrapper.find('[data-test-subj="credentialsApiUrlFromInput"]').length > 0).toBeTruthy();
expect(
wrapper.find('[data-test-subj="connector-servicenow-password-form-input"]').length > 0
).toBeTruthy();
expect(screen.getByTestId('credentialsApiUrlFromInput')).toBeInTheDocument();
expect(screen.getByTestId('connector-servicenow-password-form-input')).toBeInTheDocument();
});
it('OAuth fields are rendered correctly', () => {
render(
<ConnectorFormTestProvider connector={usesImportSetApiConnectorOauth}>
<ServiceNowConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
expect(screen.getByRole('textbox', { name: 'Client ID' })).toHaveValue(
usesImportSetApiConnectorOauth.config.clientId
);
expect(screen.getByRole('textbox', { name: 'User identifier' })).toHaveValue(
usesImportSetApiConnectorOauth.config.userIdentifierValue
);
expect(screen.getByRole('textbox', { name: 'JWT verifier key ID' })).toHaveValue(
usesImportSetApiConnectorOauth.config.jwtKeyId
);
expect(screen.getByLabelText('Client secret')).toHaveValue(
usesImportSetApiConnectorOauth.secrets.clientSecret
);
expect(screen.getByLabelText('Private key')).toHaveValue(
usesImportSetApiConnectorOauth.secrets.privateKey
);
expect(screen.getByLabelText('Private key password')).toHaveValue(
usesImportSetApiConnectorOauth.secrets.privateKeyPassword
);
});
it('sets the OAuth fields correctly', async () => {
const onSubmit = jest.fn();
const connector = {
id: 'test',
actionTypeId: '.servicenow',
isDeprecated: false,
name: 'SN',
config: { apiUrl: 'https://test.com', usesTableApi: false },
secrets: {},
};
render(
<ConnectorFormTestProvider connector={connector} onSubmit={onSubmit}>
<ServiceNowConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await userEvent.click(screen.getByTestId('use-oauth-switch'));
await userEvent.type(
await screen.findByRole('textbox', { name: 'Client ID' }),
'test-client-id'
);
await userEvent.type(
screen.getByRole('textbox', { name: 'User identifier' }),
'test-user-identifier'
);
await userEvent.type(
screen.getByRole('textbox', { name: 'JWT verifier key ID' }),
'test-jwt-key-id'
);
await userEvent.type(screen.getByLabelText('Client secret'), 'test-client-secret');
await userEvent.type(screen.getByLabelText('Private key'), 'test-private-key');
await userEvent.type(
screen.getByLabelText('Private key password'),
'test-private-key-password'
);
await userEvent.click(await screen.findByTestId('form-test-provide-submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
data: {
...connector,
config: {
...usesImportSetApiConnectorOauth.config,
clientId: 'test-client-id',
userIdentifierValue: 'test-user-identifier',
jwtKeyId: 'test-jwt-key-id',
},
secrets: {
clientSecret: 'test-client-secret',
privateKey: 'test-private-key',
privateKeyPassword: 'test-private-key-password',
},
},
isValid: true,
});
});
});
describe('Elastic certified ServiceNow application', () => {
@ -125,8 +227,8 @@ describe('ServiceNowActionConnectorFields renders', () => {
jest.clearAllMocks();
});
test('should render the correct callouts when the connectors needs the application', () => {
const wrapper = mountWithIntl(
it('should render the correct callouts when the connectors needs the application', () => {
render(
<ConnectorFormTestProvider connector={usesImportSetApiConnector}>
<ServiceNowConnectorFields
readOnly={false}
@ -136,13 +238,13 @@ describe('ServiceNowActionConnectorFields renders', () => {
</ConnectorFormTestProvider>
);
expect(wrapper.find('[data-test-subj="snInstallationCallout"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="snDeprecatedCallout"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeFalsy();
expect(screen.getByTestId('snInstallationCallout')).toBeInTheDocument();
expect(screen.queryByTestId('snDeprecatedCallout')).not.toBeInTheDocument();
expect(screen.queryByTestId('snApplicationCallout')).not.toBeInTheDocument();
});
test('should render the correct callouts if the connector uses the table API', () => {
const wrapper = mountWithIntl(
it('should render the correct callouts if the connector uses the table API', () => {
render(
<ConnectorFormTestProvider connector={usesTableApiConnector}>
<ServiceNowConnectorFields
readOnly={false}
@ -152,15 +254,15 @@ describe('ServiceNowActionConnectorFields renders', () => {
</ConnectorFormTestProvider>
);
expect(wrapper.find('[data-test-subj="snInstallationCallout"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="snDeprecatedCallout"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeFalsy();
expect(screen.queryByTestId('snInstallationCallout')).not.toBeInTheDocument();
expect(screen.getByTestId('snDeprecatedCallout')).toBeInTheDocument();
expect(screen.queryByTestId('snApplicationCallout')).not.toBeInTheDocument();
});
test('should get application information when saving the connector', async () => {
it('should get application information when saving the connector', async () => {
getAppInfoMock.mockResolvedValue(applicationInfoData);
const wrapper = mountWithIntl(
render(
<ConnectorFormTestProvider connector={usesImportSetApiConnector}>
<ServiceNowConnectorFields
readOnly={false}
@ -170,16 +272,14 @@ describe('ServiceNowActionConnectorFields renders', () => {
</ConnectorFormTestProvider>
);
await act(async () => {
await preSubmitValidator();
});
await act(async () => preSubmitValidator());
expect(getAppInfoMock).toHaveBeenCalledTimes(1);
expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeFalsy();
expect(screen.queryByTestId('snApplicationCallout')).not.toBeInTheDocument();
});
test('should NOT get application information when the connector uses the old API', async () => {
const wrapper = mountWithIntl(
it('should NOT get application information when the connector uses the old API', async () => {
render(
<ConnectorFormTestProvider connector={usesTableApiConnector}>
<ServiceNowConnectorFields
readOnly={false}
@ -189,19 +289,17 @@ describe('ServiceNowActionConnectorFields renders', () => {
</ConnectorFormTestProvider>
);
await act(async () => {
await preSubmitValidator();
});
await act(async () => preSubmitValidator());
expect(getAppInfoMock).toHaveBeenCalledTimes(0);
expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeFalsy();
expect(screen.queryByTestId('snApplicationCallout')).not.toBeInTheDocument();
});
test('should render error when save failed', async () => {
it('should render error when save failed', async () => {
const errorMessage = 'request failed';
getAppInfoMock.mockRejectedValueOnce(new Error(errorMessage));
mountWithIntl(
render(
<ConnectorFormTestProvider connector={usesImportSetApiConnector}>
<ServiceNowConnectorFields
readOnly={false}
@ -211,30 +309,25 @@ describe('ServiceNowActionConnectorFields renders', () => {
</ConnectorFormTestProvider>
);
let res: PreSubmitValidatorRes;
await act(async () => {
const res = await preSubmitValidator();
const messageWrapper = mount(<>{res?.message}</>);
expect(getAppInfoMock).toHaveBeenCalledTimes(1);
expect(
messageWrapper.find('[data-test-subj="snApplicationCallout"]').exists()
).toBeTruthy();
expect(
messageWrapper
.find('[data-test-subj="snApplicationCallout"]')
.first()
.text()
.includes(errorMessage)
).toBeTruthy();
res = await preSubmitValidator();
});
expect(getAppInfoMock).toHaveBeenCalledTimes(1);
render(<>{res?.message}</>);
expect(screen.getByTestId('errorCallout')).toBeInTheDocument();
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
test('should render error when the response is a REST api error', async () => {
it('should render error when the response is a REST api error', async () => {
const errorMessage = 'request failed';
getAppInfoMock.mockResolvedValue({ error: { message: errorMessage }, status: 'failure' });
mountWithIntl(
render(
<ConnectorFormTestProvider connector={usesImportSetApiConnector}>
<ServiceNowConnectorFields
readOnly={false}
@ -244,26 +337,50 @@ describe('ServiceNowActionConnectorFields renders', () => {
</ConnectorFormTestProvider>
);
let res: PreSubmitValidatorRes;
await act(async () => {
const res = await preSubmitValidator();
const messageWrapper = mount(<>{res?.message}</>);
expect(getAppInfoMock).toHaveBeenCalledTimes(1);
expect(
messageWrapper.find('[data-test-subj="snApplicationCallout"]').exists()
).toBeTruthy();
expect(
messageWrapper
.find('[data-test-subj="snApplicationCallout"]')
.first()
.text()
.includes(errorMessage)
).toBeTruthy();
res = await preSubmitValidator();
});
expect(getAppInfoMock).toHaveBeenCalledTimes(1);
render(<>{res?.message}</>);
expect(screen.getByTestId('errorCallout')).toBeInTheDocument();
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
test('should migrate the deprecated connector correctly', async () => {
it('should render an application required error when the error is a CORS error', async () => {
const error = new Error('my cors error');
error.name = 'TypeError';
getAppInfoMock.mockRejectedValueOnce(error);
render(
<ConnectorFormTestProvider connector={usesImportSetApiConnector}>
<ServiceNowConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={registerPreSubmitValidator}
/>
</ConnectorFormTestProvider>
);
let res: PreSubmitValidatorRes;
await act(async () => {
res = await preSubmitValidator();
});
expect(getAppInfoMock).toHaveBeenCalledTimes(1);
render(<>{res?.message}</>);
expect(screen.getByTestId('snApplicationCallout')).toBeInTheDocument();
});
it('should migrate the deprecated connector correctly', async () => {
getAppInfoMock.mockResolvedValue(applicationInfoData);
updateActionConnectorMock.mockResolvedValue({ isDeprecated: false });
@ -322,7 +439,7 @@ describe('ServiceNowActionConnectorFields renders', () => {
expect(await screen.findByTestId('snInstallationCallout')).toBeInTheDocument();
});
test('should NOT migrate the deprecated connector when there is an error', async () => {
it('should NOT migrate the deprecated connector when there is an error', async () => {
const errorMessage = 'request failed';
getAppInfoMock.mockRejectedValueOnce(new Error(errorMessage));
updateActionConnectorMock.mockResolvedValue({ isDeprecated: false });

View file

@ -21,12 +21,13 @@ import { snExternalServiceConfig } from '../../../../common/servicenow_config';
import { DeprecatedCallout } from './deprecated_callout';
import { useGetAppInfo } from './use_get_app_info';
import { ApplicationRequiredCallout } from './application_required_callout';
import { isRESTApiError } from './helpers';
import { isCORSError, isRESTApiError } from './helpers';
import { InstallationCallout } from './installation_callout';
import { UpdateConnector, UpdateConnectorFormSchema } from './update_connector';
import { Credentials } from './credentials';
import * as i18n from './translations';
import { ServiceNowActionConnector, ServiceNowConfig, ServiceNowSecrets } from './types';
import { ErrorCallout } from './error_callout';
// eslint-disable-next-line import/no-default-export
export { ServiceNowConnectorFields as default };
@ -41,20 +42,8 @@ const ServiceNowConnectorFields: React.FC<ActionConnectorFieldsProps> = ({
notifications: { toasts },
} = useKibana().services;
const { updateFieldValues } = useFormContext();
const [{ id, isDeprecated, actionTypeId, name, config, secrets }] = useFormData<
ConnectorFormSchema<ServiceNowConfig, ServiceNowSecrets>
>({
watch: [
'id',
'isDeprecated',
'actionTypeId',
'name',
'config.apiUrl',
'config.isOAuth',
'secrets.username',
'secrets.password',
],
});
const [{ id, isDeprecated, actionTypeId, name, config, secrets }] =
useFormData<ConnectorFormSchema<ServiceNowConfig, ServiceNowSecrets>>();
const requiresNewApplication = isDeprecated != null ? !isDeprecated : true;
const { isOAuth = false } = config ?? {};
@ -97,13 +86,19 @@ const ServiceNowConnectorFields: React.FC<ActionConnectorFieldsProps> = ({
try {
await getApplicationInfo(action);
} catch (error) {
if (isCORSError(error)) {
return {
message: (
<ApplicationRequiredCallout
appId={actionTypeId != null ? snExternalServiceConfig[actionTypeId]?.appId : ''}
message={error.message}
/>
),
};
}
return {
message: (
<ApplicationRequiredCallout
appId={actionTypeId != null ? snExternalServiceConfig[actionTypeId]?.appId : ''}
message={error.message}
/>
),
message: <ErrorCallout message={error.message} />,
};
}
}

View file

@ -11,6 +11,7 @@ import { useGetAppInfo, UseGetAppInfo, UseGetAppInfoProps } from './use_get_app_
import { getAppInfo } from './api';
import { ServiceNowActionConnector } from './types';
import { httpServiceMock } from '@kbn/core/public/mocks';
import { CORSError } from './cors_error';
jest.mock('./api');
jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana');
@ -100,8 +101,8 @@ describe('useGetAppInfo', () => {
).rejects.toThrow('An error occurred');
});
it('it throws an error when fetch fails', async () => {
expect.assertions(1);
it('it throws a CORS error on CORS errors', async () => {
expect.assertions(2);
getAppInfoMock.mockImplementation(() => {
const error = new Error('An error occurred');
error.name = 'TypeError';
@ -115,12 +116,15 @@ describe('useGetAppInfo', () => {
})
);
await expect(() =>
act(async () => {
try {
await act(async () => {
await result.current.fetchAppInfo(actionConnector);
})
).rejects.toThrow(
'Failed to fetch. Check the URL or the CORS configuration of your ServiceNow instance.'
);
});
} catch (e) {
expect(e).toBeInstanceOf(CORSError);
expect(e.message).toBe(
'Failed to fetch. Check the URL or the CORS configuration of your ServiceNow instance.'
);
}
});
});

View file

@ -11,6 +11,7 @@ import { HttpStart } from '@kbn/core/public';
import { getAppInfo } from './api';
import { AppInfo, RESTApiError, ServiceNowActionConnector } from './types';
import { FETCH_ERROR } from './translations';
import { CORSError } from './cors_error';
export interface UseGetAppInfoProps {
actionTypeId?: string;
@ -66,7 +67,7 @@ export const useGetAppInfo = ({ actionTypeId, http }: UseGetAppInfoProps): UseGe
* in the ServiceNow instance is needed by our ServiceNow applications.
*/
if (error.name === 'TypeError') {
throw new Error(FETCH_ERROR);
throw new CORSError(FETCH_ERROR);
}
throw error;