diff --git a/awx/ui_next/src/api/models/CredentialTypes.js b/awx/ui_next/src/api/models/CredentialTypes.js index dab1676231..39247b5ebc 100644 --- a/awx/ui_next/src/api/models/CredentialTypes.js +++ b/awx/ui_next/src/api/models/CredentialTypes.js @@ -27,6 +27,10 @@ class CredentialTypes extends Base { .concat(nextResults) .filter(type => acceptableKinds.includes(type.kind)); } + + test(id, data) { + return this.http.post(`${this.baseUrl}${id}/test/`, data); + } } export default CredentialTypes; diff --git a/awx/ui_next/src/api/models/Credentials.js b/awx/ui_next/src/api/models/Credentials.js index 95e954fc0c..13ee1f8a9c 100644 --- a/awx/ui_next/src/api/models/Credentials.js +++ b/awx/ui_next/src/api/models/Credentials.js @@ -25,6 +25,10 @@ class Credentials extends Base { params, }); } + + test(id, data) { + return this.http.post(`${this.baseUrl}${id}/test/`, data); + } } export default Credentials; diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx index bc486bb8b1..5a76a76271 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx @@ -1,16 +1,19 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Formik, useField } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { arrayOf, func, object, shape } from 'prop-types'; -import { Form, FormGroup } from '@patternfly/react-core'; +import { ActionGroup, Button, Form, FormGroup } from '@patternfly/react-core'; import FormField, { FormSubmitError } from '../../../components/FormField'; -import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup'; +import { + FormColumnLayout, + FormFullWidthLayout, +} from '../../../components/FormLayout'; import AnsibleSelect from '../../../components/AnsibleSelect'; import { required } from '../../../util/validators'; import OrganizationLookup from '../../../components/Lookup/OrganizationLookup'; -import { FormColumnLayout } from '../../../components/FormLayout'; import TypeInputsSubForm from './TypeInputsSubForm'; +import ExternalTestModal from './ExternalTestModal'; function CredentialFormFields({ i18n, @@ -139,6 +142,7 @@ function CredentialFormFields({ } function CredentialForm({ + i18n, credential = {}, credentialTypes, inputSources, @@ -147,6 +151,7 @@ function CredentialForm({ submitError, ...rest }) { + const [showExternalTestModal, setShowExternalTestModal] = useState(false); const initialValues = { name: credential.name || '', description: credential.description || '', @@ -205,21 +210,61 @@ function CredentialForm({ }} > {formik => ( -
- - + + + + + + + + {formik?.values?.credential_type && + credentialTypes[formik.values.credential_type]?.kind === + 'external' && ( + + )} + + + + + + {showExternalTestModal && ( + setShowExternalTestModal(false)} /> - - -
- + )} + )} ); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx index d23e5347ee..f4360e75ba 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx @@ -99,6 +99,9 @@ describe('', () => { test('should display form fields on add properly', async () => { addFieldExpects(); }); + test('should hide Test button initially', () => { + expect(wrapper.find('Button[children="Test"]').length).toBe(0); + }); test('should update form values', async () => { // name and description change await act(async () => { @@ -221,6 +224,18 @@ describe('', () => { 'There was an error parsing the file. Please check the file formatting and try again.' ); }); + test('should show Test button when external credential type is selected', async () => { + await act(async () => { + await wrapper + .find('AnsibleSelect[id="credential_type"]') + .invoke('onChange')(null, 21); + }); + wrapper.update(); + expect(wrapper.find('Button[children="Test"]').length).toBe(1); + expect(wrapper.find('Button[children="Test"]').props().isDisabled).toBe( + true + ); + }); test('should call handleCancel when Cancel button is clicked', async () => { expect(onCancel).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx index aafa1c74fe..51c9dfa02d 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx @@ -110,7 +110,7 @@ function CredentialField({ credentialType, fieldOptions, i18n }) { > { helpers.setValue(value); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginTestAlert.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginTestAlert.jsx new file mode 100644 index 0000000000..f1c4a4ae97 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginTestAlert.jsx @@ -0,0 +1,91 @@ +import React, { useEffect, useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { string, shape } from 'prop-types'; +import { + Alert, + AlertActionCloseButton, + AlertGroup, +} from '@patternfly/react-core'; + +function CredentialPluginTestAlert({ + i18n, + credentialName, + successResponse, + errorResponse, +}) { + const [testMessage, setTestMessage] = useState(''); + const [testVariant, setTestVariant] = useState(false); + + useEffect(() => { + if (errorResponse) { + if (errorResponse?.response?.data?.inputs) { + if (errorResponse.response.data.inputs.startsWith('HTTP')) { + const [ + errorCode, + errorStr, + ] = errorResponse.response.data.inputs.split('\n'); + try { + const errorJSON = JSON.parse(errorStr); + setTestMessage( + `${errorCode}${ + errorJSON?.errors[0] ? `: ${errorJSON.errors[0]}` : '' + }` + ); + } catch { + setTestMessage(errorResponse.response.data.inputs); + } + } else { + setTestMessage(errorResponse.response.data.inputs); + } + } else { + setTestMessage( + i18n._( + t`Something went wrong with the request to test this credential and metadata.` + ) + ); + } + setTestVariant('danger'); + } else if (successResponse) { + setTestMessage(i18n._(t`Test passed`)); + setTestVariant('success'); + } + }, [i18n, successResponse, errorResponse]); + + return ( + + {testMessage && testVariant && ( + { + setTestMessage(null); + setTestVariant(null); + }} + /> + } + title={ + <> + {credentialName} +

{testMessage}

+ + } + variant={testVariant} + /> + )} +
+ ); +} + +CredentialPluginTestAlert.propTypes = { + credentialName: string.isRequired, + successResponse: shape({}), + errorResponse: shape({}), +}; + +CredentialPluginTestAlert.defaultProps = { + successResponse: null, + errorResponse: null, +}; + +export default withI18n()(CredentialPluginTestAlert); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/index.js b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/index.js index 033586567f..3799206eb4 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/index.js +++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/index.js @@ -1,2 +1,3 @@ export { default as CredentialPluginSelected } from './CredentialPluginSelected'; export { default as CredentialPluginField } from './CredentialPluginField'; +export { default as CredentialPluginTestAlert } from './CredentialPluginTestAlert'; diff --git a/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx b/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx new file mode 100644 index 0000000000..fda8bc4492 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx @@ -0,0 +1,198 @@ +import React, { useCallback } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import { func, shape } from 'prop-types'; +import { Formik } from 'formik'; +import { + Button, + Form, + FormGroup, + Modal, + Tooltip, +} from '@patternfly/react-core'; +import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; +import { CredentialsAPI, CredentialTypesAPI } from '../../../api'; +import AnsibleSelect from '../../../components/AnsibleSelect'; +import FormField from '../../../components/FormField'; +import { FormFullWidthLayout } from '../../../components/FormLayout'; +import { required } from '../../../util/validators'; +import useRequest from '../../../util/useRequest'; +import { CredentialPluginTestAlert } from './CredentialFormFields/CredentialPlugins'; + +const QuestionCircleIcon = styled(PFQuestionCircleIcon)` + margin-left: 10px; +`; + +function ExternalTestModal({ + i18n, + credential, + credentialType, + credentialFormValues, + onClose, +}) { + const { + result: testPluginSuccess, + error: testPluginError, + request: testPluginMetadata, + } = useRequest( + useCallback( + async values => { + const payload = { + inputs: credentialType.inputs.fields.reduce( + (filteredInputs, field) => { + filteredInputs[field.id] = credentialFormValues.inputs[field.id]; + return filteredInputs; + }, + {} + ), + metadata: values, + }; + + if (credential && credential.credential_type === credentialType.id) { + return CredentialsAPI.test(credential.id, payload); + } + return CredentialTypesAPI.test(credentialType.id, payload); + }, + [ + credential, + credentialType.id, + credentialType.inputs.fields, + credentialFormValues.inputs, + ] + ), + null + ); + + const handleTest = async values => { + await testPluginMetadata(values); + }; + + return ( + <> + { + if (field.type === 'string' && field.choices) { + initialValues[field.id] = field.default || field.choices[0]; + } else { + initialValues[field.id] = ''; + } + return initialValues; + }, + {} + )} + onSubmit={values => handleTest(values)} + > + {({ handleSubmit, setFieldValue }) => ( + onClose()} + variant="small" + actions={[ + , + , + ]} + > +
+ + {credentialType.inputs.metadata.map(field => { + const isRequired = credentialType.inputs?.required.includes( + field.id + ); + if (field.type === 'string') { + if (field.choices) { + return ( + + + + ) + } + isRequired={isRequired} + > + { + return { + value: choice, + key: choice, + label: choice, + }; + })} + onChange={(event, value) => { + setFieldValue(field.id, value); + }} + validate={isRequired ? required(null, i18n) : null} + /> + + ); + } + + return ( + + ); + } + + return null; + })} + +
+
+ )} +
+ + + ); +} + +ExternalTestModal.proptype = { + credential: shape({}), + credentialType: shape({}).isRequired, + credentialFormValues: shape({}).isRequired, + onClose: func.isRequired, +}; + +ExternalTestModal.defaultProps = { + credential: null, +}; + +export default withI18n()(ExternalTestModal); diff --git a/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.test.jsx b/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.test.jsx new file mode 100644 index 0000000000..91677795aa --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.test.jsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { CredentialsAPI, CredentialTypesAPI } from '../../../api'; +import ExternalTestModal from './ExternalTestModal'; +import credentialTypesArr from './data.credentialTypes.json'; + +jest.mock('../../../api/models/Credentials'); +jest.mock('../../../api/models/CredentialTypes'); + +const credentialType = credentialTypesArr.find( + credType => credType.namespace === 'hashivault_kv' +); + +const credentialFormValues = { + name: 'Foobar', + credential_type: credentialType.id, + inputs: { + api_version: 'v2', + token: '$encrypted$', + url: 'http://hashivault:8200', + }, +}; + +const credential = { + id: 1, + name: 'A credential', + credential_type: credentialType.id, +}; + +describe('', () => { + let wrapper; + afterEach(() => wrapper.unmount()); + test('should display metadata fields correctly', async () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.find('FormField').length).toBe(5); + expect(wrapper.find('input#credential-secret_backend').length).toBe(1); + expect(wrapper.find('input#credential-secret_path').length).toBe(1); + expect(wrapper.find('input#credential-auth_path').length).toBe(1); + expect(wrapper.find('input#credential-secret_key').length).toBe(1); + expect(wrapper.find('input#credential-secret_version').length).toBe(1); + }); + test('should make the test request correctly when testing an existing credential', async () => { + wrapper = mountWithContexts( + + ); + await act(async () => { + wrapper.find('input#credential-secret_path').simulate('change', { + target: { value: '/secret/foo/bar/baz', name: 'secret_path' }, + }); + wrapper.find('input#credential-secret_key').simulate('change', { + target: { value: 'password', name: 'secret_key' }, + }); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Button[children="Run"]').simulate('click'); + }); + expect(CredentialsAPI.test).toHaveBeenCalledWith(1, { + inputs: { + api_version: 'v2', + cacert: undefined, + role_id: undefined, + secret_id: undefined, + token: '$encrypted$', + url: 'http://hashivault:8200', + }, + metadata: { + auth_path: '', + secret_backend: '', + secret_key: 'password', + secret_path: '/secret/foo/bar/baz', + secret_version: '', + }, + }); + }); + test('should make the test request correctly when testing a new credential', async () => { + wrapper = mountWithContexts( + + ); + await act(async () => { + wrapper.find('input#credential-secret_path').simulate('change', { + target: { value: '/secret/foo/bar/baz', name: 'secret_path' }, + }); + wrapper.find('input#credential-secret_key').simulate('change', { + target: { value: 'password', name: 'secret_key' }, + }); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Button[children="Run"]').simulate('click'); + }); + expect(CredentialTypesAPI.test).toHaveBeenCalledWith(21, { + inputs: { + api_version: 'v2', + cacert: undefined, + role_id: undefined, + secret_id: undefined, + token: '$encrypted$', + url: 'http://hashivault:8200', + }, + metadata: { + auth_path: '', + secret_backend: '', + secret_key: 'password', + secret_path: '/secret/foo/bar/baz', + secret_version: '', + }, + }); + }); + test('should display the alert after a successful test', async () => { + CredentialTypesAPI.test.mockResolvedValue({}); + wrapper = mountWithContexts( + + ); + await act(async () => { + wrapper.find('input#credential-secret_path').simulate('change', { + target: { value: '/secret/foo/bar/baz', name: 'secret_path' }, + }); + wrapper.find('input#credential-secret_key').simulate('change', { + target: { value: 'password', name: 'secret_key' }, + }); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Button[children="Run"]').simulate('click'); + }); + wrapper.update(); + expect(wrapper.find('Alert').length).toBe(1); + expect(wrapper.find('Alert').props().variant).toBe('success'); + }); + test('should display the alert after a failed test', async () => { + CredentialTypesAPI.test.mockRejectedValue({ + inputs: `HTTP 404 + {"errors":["no handler for route '/secret/foo/bar/baz'"]} + `, + }); + wrapper = mountWithContexts( + + ); + await act(async () => { + wrapper.find('input#credential-secret_path').simulate('change', { + target: { value: '/secret/foo/bar/baz', name: 'secret_path' }, + }); + wrapper.find('input#credential-secret_key').simulate('change', { + target: { value: 'password', name: 'secret_key' }, + }); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Button[children="Run"]').simulate('click'); + }); + wrapper.update(); + expect(wrapper.find('Alert').length).toBe(1); + expect(wrapper.find('Alert').props().variant).toBe('danger'); + }); +}); diff --git a/awx/ui_next/src/screens/Credential/shared/index.js b/awx/ui_next/src/screens/Credential/shared/index.js index ad01a03c29..28dda5128a 100644 --- a/awx/ui_next/src/screens/Credential/shared/index.js +++ b/awx/ui_next/src/screens/Credential/shared/index.js @@ -1,2 +1,3 @@ export { default as mockCredentials } from './data.credentials.json'; export { default as mockCredentialType } from './data.credential_type.json'; +export { default as ExternalTestModal } from './ExternalTestModal'; diff --git a/awx/ui_next/src/util/useRequest.js b/awx/ui_next/src/util/useRequest.js index 0e95be4a69..027e82f86f 100644 --- a/awx/ui_next/src/util/useRequest.js +++ b/awx/ui_next/src/util/useRequest.js @@ -38,6 +38,9 @@ export default function useRequest(makeRequest, initialValue) { request: useCallback( async (...args) => { setIsLoading(true); + if (isMounted.current) { + setError(null); + } try { const response = await makeRequest(...args); if (isMounted.current) {