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 => (
-
+ )}
+ >
)}
);
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={[
+ ,
+ ,
+ ]}
+ >
+
+
+ )}
+
+
+ >
+ );
+}
+
+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) {