mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[8.16] [ResponseOps][Connectors] Fix bug with OAuth form in the ServiceNow connector (#213658) (#213866)
# Backport This will backport the following commits from `main` to `8.16`: - [[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:
parent
aa17756cee
commit
b207ba8623
13 changed files with 451 additions and 139 deletions
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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();
|
||||
|
|
|
@ -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[]
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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} />,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue